From f62d27447820727e4b885608645ce45a85dd69f6 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 27 Apr 2026 12:42:20 -0700 Subject: [PATCH 01/28] fix(mothership): stabilize task sidebar ordering on selection (#4309) --- .../w/components/sidebar/sidebar.tsx | 5 +---- apps/sim/hooks/queries/tasks.ts | 16 ++++++++-------- apps/sim/hooks/use-task-events.test.ts | 7 ++----- apps/sim/hooks/use-task-events.ts | 4 ++-- 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 8f81c5db747..57465d4edc8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -206,10 +206,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({ e.preventDefault() onMultiSelectClick(task.id, true) } else { - useFolderStore.setState({ - selectedTasks: new Set(), - lastSelectedTaskId: task.id, - }) + useFolderStore.getState().selectTaskOnly(task.id) } }} onContextMenu={task.id !== 'new' ? (e) => onContextMenu(e, task.id) : undefined} diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts index ebfd65bd38f..5ce71a8ee03 100644 --- a/apps/sim/hooks/queries/tasks.ts +++ b/apps/sim/hooks/queries/tasks.ts @@ -1,4 +1,4 @@ -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' import { @@ -254,7 +254,6 @@ export function useTasks(workspaceId?: string) { queryKey: taskKeys.list(workspaceId), queryFn: ({ signal }) => fetchTasks(workspaceId as string, signal), enabled: Boolean(workspaceId), - placeholderData: keepPreviousData, staleTime: 60 * 1000, }) } @@ -535,6 +534,10 @@ async function markTaskUnread(chatId: string): Promise { /** * Marks a task as read with optimistic update. + * + * The server only updates `lastSeenAt`, never `updatedAt`, so we deliberately + * do not invalidate the list cache — that would trigger a refetch that can + * reorder the sidebar if any unrelated server-side update landed in between. */ export function useMarkTaskRead(workspaceId?: string) { const queryClient = useQueryClient() @@ -556,14 +559,14 @@ export function useMarkTaskRead(workspaceId?: string) { queryClient.setQueryData(taskKeys.list(workspaceId), context.previousTasks) } }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) }) - }, }) } /** * Marks a task as unread with optimistic update. + * + * Same rationale as `useMarkTaskRead` — no list invalidation, since the server + * only flips `lastSeenAt` and the optimistic update fully reflects the change. */ export function useMarkTaskUnread(workspaceId?: string) { const queryClient = useQueryClient() @@ -585,8 +588,5 @@ export function useMarkTaskUnread(workspaceId?: string) { queryClient.setQueryData(taskKeys.list(workspaceId), context.previousTasks) } }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) }) - }, }) } diff --git a/apps/sim/hooks/use-task-events.test.ts b/apps/sim/hooks/use-task-events.test.ts index d62b32696a6..2e68175b935 100644 --- a/apps/sim/hooks/use-task-events.test.ts +++ b/apps/sim/hooks/use-task-events.test.ts @@ -50,12 +50,9 @@ describe('handleTaskStatusEvent', () => { }) }) - it('preserves list invalidation when task event payload is invalid', () => { + it('does not invalidate when task event payload is invalid', () => { handleTaskStatusEvent(queryClient, 'ws-1', '{') - expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1) - expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: taskKeys.list('ws-1'), - }) + expect(queryClient.invalidateQueries).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/hooks/use-task-events.ts b/apps/sim/hooks/use-task-events.ts index e10a7599b13..0d6d4f7d0e9 100644 --- a/apps/sim/hooks/use-task-events.ts +++ b/apps/sim/hooks/use-task-events.ts @@ -41,13 +41,13 @@ export function handleTaskStatusEvent( workspaceId: string, data: unknown ): void { - queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) }) - const payload = parseTaskStatusEventPayload(data) if (!payload) { logger.warn('Received invalid task_status payload') return } + + queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) }) } /** From 74946fb162ac7f1a1f5d6150b0a5fa3c8b963b35 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 27 Apr 2026 13:34:57 -0700 Subject: [PATCH 02/28] improvement(docker): speed up app image build with cache mounts and parallel node-gyp (#4310) --- docker/app.Dockerfile | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 5e79287c63d..1db008a0ab2 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -45,15 +45,17 @@ COPY packages/workflow-types/package.json ./packages/workflow-types/package.json # Install dependencies, then rebuild isolated-vm for Node.js # Use --linker=hoisted for flat node_modules layout (required for Docker multi-stage builds) +# JOBS=4 caps node-gyp parallelism — higher values OOM isolated-vm (laverdet/isolated-vm#428) RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \ --mount=type=cache,id=npm-cache,target=/root/.npm \ HUSKY=0 bun install --omit=dev --ignore-scripts --linker=hoisted && \ - cd node_modules/isolated-vm && npx node-gyp rebuild --release + cd node_modules/isolated-vm && JOBS=4 npx node-gyp rebuild --release # ======================================== # Builder Stage: Build the Application # ======================================== FROM base AS builder +ARG TARGETPLATFORM WORKDIR /app # Install turbo globally (cached for fast reinstall) @@ -88,17 +90,10 @@ COPY apps/sim/postcss.config.mjs ./apps/sim/postcss.config.mjs COPY apps/sim ./apps/sim COPY packages ./packages -# Required for standalone nextjs build -WORKDIR /app/apps/sim -RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \ - HUSKY=0 bun install sharp --linker=hoisted - ENV NEXT_TELEMETRY_DISABLED=1 \ VERCEL_TELEMETRY_DISABLED=1 \ DOCKER_BUILD=1 -WORKDIR /app - # Provide dummy database URLs during image build so server code that imports @sim/db # can be evaluated without crashing. Runtime environments should override these. ARG DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" @@ -109,7 +104,10 @@ ENV DATABASE_URL=${DATABASE_URL} ARG NEXT_PUBLIC_APP_URL="http://localhost:3000" ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} -RUN bun run build +# Per-platform cache id keeps arm64/amd64 SWC artifacts isolated. +RUN --mount=type=cache,id=next-cache-${TARGETPLATFORM},target=/app/apps/sim/.next/cache \ + --mount=type=cache,id=turbo-cache-${TARGETPLATFORM},target=/app/.turbo \ + bun run build # ======================================== # Runner Stage: Run the actual app From 896a00ae314b8daebc5655113c08334a6922cab7 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 27 Apr 2026 14:30:47 -0700 Subject: [PATCH 03/28] fix(security): require internal API key for copilot training endpoints (#4311) --- apps/sim/app/api/copilot/training/examples/route.ts | 9 +++------ apps/sim/app/api/copilot/training/route.ts | 9 +++------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/api/copilot/training/examples/route.ts b/apps/sim/app/api/copilot/training/examples/route.ts index 1e6a5aa6574..7f68cf812c4 100644 --- a/apps/sim/app/api/copilot/training/examples/route.ts +++ b/apps/sim/app/api/copilot/training/examples/route.ts @@ -1,10 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { - authenticateCopilotRequestSessionOnly, - createUnauthorizedResponse, -} from '@/lib/copilot/request/http' +import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -21,8 +18,8 @@ const TrainingExampleSchema = z.object({ }) export const POST = withRouteHandler(async (request: NextRequest) => { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { + const auth = checkInternalApiKey(request) + if (!auth.success) { return createUnauthorizedResponse() } diff --git a/apps/sim/app/api/copilot/training/route.ts b/apps/sim/app/api/copilot/training/route.ts index 1c1e64ab0e9..637928b23a9 100644 --- a/apps/sim/app/api/copilot/training/route.ts +++ b/apps/sim/app/api/copilot/training/route.ts @@ -1,10 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { - authenticateCopilotRequestSessionOnly, - createUnauthorizedResponse, -} from '@/lib/copilot/request/http' +import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -27,8 +24,8 @@ const TrainingDataSchema = z.object({ }) export const POST = withRouteHandler(async (request: NextRequest) => { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { + const auth = checkInternalApiKey(request) + if (!auth.success) { return createUnauthorizedResponse() } From 8266f0afdbd88a8e6d15b72b00f04c1b503069cd Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 27 Apr 2026 14:51:58 -0700 Subject: [PATCH 04/28] fix(security): rate limit chat OTP + validate mothership proxy endpoint (#4312) * fix(security): rate limit chat OTP endpoint to prevent email bombing * fix(security): validate mothership proxy endpoint to block path traversal * fix(security): address greptile feedback on OTP rate limiting --- apps/sim/app/api/admin/mothership/route.ts | 16 ++ .../api/chat/[identifier]/otp/route.test.ts | 145 ++++++++++++++++++ .../app/api/chat/[identifier]/otp/route.ts | 54 ++++++- 3 files changed, 214 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/admin/mothership/route.ts b/apps/sim/app/api/admin/mothership/route.ts index 19e4a029ec4..d34efca9e50 100644 --- a/apps/sim/app/api/admin/mothership/route.ts +++ b/apps/sim/app/api/admin/mothership/route.ts @@ -16,6 +16,14 @@ function getMothershipUrl(environment: string): string | null { return ENV_URLS[environment] ?? null } +const ENDPOINT_PATTERN = /^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/ + +function isValidEndpoint(endpoint: string): boolean { + if (!endpoint) return false + if (endpoint.includes('..')) return false + return ENDPOINT_PATTERN.test(endpoint) +} + async function isAdminRequestAuthorized() { const session = await getSession() if (!session?.user?.id) return false @@ -57,6 +65,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'endpoint query param required' }, { status: 400 }) } + if (!isValidEndpoint(endpoint)) { + return NextResponse.json({ error: 'invalid endpoint' }, { status: 400 }) + } + const baseUrl = getMothershipUrl(environment) if (!baseUrl) { return NextResponse.json( @@ -108,6 +120,10 @@ export const GET = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'endpoint query param required' }, { status: 400 }) } + if (!isValidEndpoint(endpoint)) { + return NextResponse.json({ error: 'invalid endpoint' }, { status: 400 }) + } + const baseUrl = getMothershipUrl(environment) if (!baseUrl) { return NextResponse.json( diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index 8069757ea79..fa8da3f97c5 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -112,6 +112,16 @@ 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, })) @@ -234,6 +244,13 @@ describe('Chat OTP API Route', () => { })) 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) @@ -283,6 +300,134 @@ describe('Chat OTP API Route', () => { }) }) + describe('POST - Rate limiting', () => { + const buildDeploymentSelect = () => + mockDbSelect.mockImplementationOnce(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: mockChatId, + authType: 'email', + allowedEmails: [mockEmail], + title: 'Test Chat', + }, + ]), + }), + }), + })) + + 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/chat/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 }, + })) + + buildDeploymentSelect() + + const request = new NextRequest('http://localhost:3000/api/chat/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('falls back to refill interval when retryAfterMs is missing', async () => { + mockCheckRateLimitDirect.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + 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/chat/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(headerSet).toHaveBeenCalledWith('Retry-After', '900') + }) + + it('skips IP rate limit when client IP is unknown', async () => { + requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('unknown') + buildDeploymentSelect() + + const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { + method: 'POST', + body: JSON.stringify({ email: mockEmail }), + }) + + await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + // Only the email-scoped check should run, not the IP-scoped one + expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1) + expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( + expect.stringContaining('chat-otp:email:'), + expect.any(Object) + ) + }) + }) + describe('POST - Store OTP (Database path)', () => { beforeEach(() => { mockGetStorageMethod.mockReturnValue('database') diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index 433159ff600..9010f9af464 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -8,9 +8,11 @@ import type { NextRequest } from 'next/server' import { z } from 'zod' import { renderOTPEmail } from '@/components/emails' import { getRedisClient } from '@/lib/core/config/redis' +import type { TokenBucketConfig } from '@/lib/core/rate-limiter' +import { RateLimiter } from '@/lib/core/rate-limiter' import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment' import { getStorageMethod } from '@/lib/core/storage' -import { generateRequestId } from '@/lib/core/utils/request' +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 { setChatAuthCookie } from '@/app/api/chat/utils' @@ -18,6 +20,20 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/ const logger = createLogger('ChatOtpAPI') +const rateLimiter = new RateLimiter() + +const OTP_IP_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 10, + refillRate: 10, + refillIntervalMs: 15 * 60_000, +} + +const OTP_EMAIL_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 3, + refillRate: 3, + refillIntervalMs: 15 * 60_000, +} + function generateOTP(): string { return randomInt(100000, 1000000).toString() } @@ -214,6 +230,23 @@ export const POST = withRouteHandler( const requestId = generateRequestId() try { + const ip = getClientIp(request) + if (ip !== 'unknown') { + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `chat-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 addCorsHeaders(response, request) + } + } + const body = await request.json() const { email } = otpRequestSchema.parse(body) @@ -255,6 +288,25 @@ export const POST = withRouteHandler( ) } + const emailRateLimit = await rateLimiter.checkRateLimitDirect( + `chat-otp:email:${deployment.id}:${email.toLowerCase()}`, + OTP_EMAIL_RATE_LIMIT + ) + if (!emailRateLimit.allowed) { + logger.warn( + `[${requestId}] OTP email rate limit exceeded for ${email} on chat ${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 addCorsHeaders(response, request) + } + const otp = generateOTP() await storeOTP(email, deployment.id, otp) From 2502369122debde3edf63705e3676e434d4781a1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 27 Apr 2026 15:07:00 -0700 Subject: [PATCH 05/28] feat(integrations): SAP S/4HANA (#4301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(integrations): SAP S/4HANA tools, block, and proxy with multi-deployment support * fix(sap_s4hana): address PR review comments - Validate baseUrl/tokenUrl in Zod schema and at runtime to prevent SSRF (https-only, deny loopback/link-local/cloud-metadata hosts) - Cap proxy token cache at 500 entries with LRU eviction - Add 30s timeout to outbound token, CSRF, and OData fetches - Make parseJsonInput return T | undefined so missing input is type-safe - Reset authType when deploymentType changes and surface OAuth fields whenever auth is not basic, so cloud_public users always see clientId/ clientSecret after switching from a basic-auth private deployment - Reject OData service names that are not uppercase identifiers and paths containing ".." or "." traversal segments Co-Authored-By: Claude Opus 4.7 * fix(sap_s4hana): allow versioned service names; tighten proxy SSRF defenses - Permit ";v=NNNN" suffix on ServiceName regex so the four delivery tools (API_OUTBOUND_DELIVERY_SRV;v=0002, API_INBOUND_DELIVERY_SRV;v=0002) pass schema validation - Restrict subdomain to RFC 1123 label characters and region to lowercase alphanumeric short codes; run the constructed cloud_public host through assertSafeExternalUrl so a crafted subdomain (e.g. "evil.com#") cannot redirect requests carrying SAP credentials - Block RFC-1918 (10/8, 172.16/12, 192.168/16), 127/8, 169.254/16, and 0.0.0.0 via isPrivateIPv4, plus IPv4-mapped IPv6 variants (::ffff:10.0.0.1, ::10.0.0.1) so private internal hosts cannot be reached from baseUrl, tokenUrl, or the resolved cloud_public URL Co-Authored-By: Claude Opus 4.7 * fix(sap_s4hana): catch hex-form IPv4-mapped IPv6 in SSRF check The WHATWG URL parser normalizes IPv4-mapped IPv6 addresses to hex form (e.g. [::ffff:169.254.169.254] → [::ffff:a9fe:a9fe]), which slipped past the dotted-decimal-only extractor. Decode the trailing two 16-bit hex groups back into IPv4 octets and run them through isPrivateIPv4. Also add isPrivateOrLoopbackIPv6 so pure IPv6 loopback (::, ::1), unique local addresses (fc00::/7), and link-local (fe80::/10) cannot be reached. Co-Authored-By: Claude Opus 4.7 * fix(sap_s4hana): scope CSRF metadata fetch and isolate token cache by secret - buildOdataUrl skips request query params when called with an internal pathOverride so the /$metadata CSRF probe never carries user OData options ($filter, $top, $select), which were causing write operations through the generic odata_query tool to fail. - tokenCacheKey now mixes a sha256 hash of clientSecret into the cache key so two tenants sharing the same tokenUrl + clientId but different secrets get isolated entries (no cross-tenant token leak). Co-Authored-By: Claude Opus 4.7 * fix(sap_s4hana): reject ?/# in service path; trim long update tool descriptions - ServicePath validator now rejects "?" and "#" so a caller can't smuggle query options through the path field (e.g., "/A_BusinessPartner?$format=atomsvc"); the Zod refine now reports ".." / "." segments, "?", and "#" together. - Update Customer / Update Supplier / Update Purchase Requisition tool descriptions exceeded the docs generator's 600-char regex window, so they were rendering with empty descriptions on the integrations landing page. Trimmed them to fit while keeping the limited-fields note and the If-Match guidance, then regenerated integrations.json and tool docs. Co-Authored-By: Claude Opus 4.7 * fix(sap_s4hana): reject percent-encoded path traversal; widen Set-Cookie split - ServicePath now also rejects %2e/%2E, %2f/%2F, %5c/%5C, %3f/%3F, %23 so a caller cannot smuggle ".." / "." / "/" / "\" / "?" / "#" past the validator and have SAP's ABAP/ICM gateway decode them server-side. - joinSetCookies fallback regex now allows the ", " separator that's used when multiple Set-Cookie values are folded onto one header line (older runtimes without Headers.getSetCookie). Prevents CSRF cookies from being concatenated into a single value during write operations. Co-Authored-By: Claude Opus 4.7 * fix(sap_s4hana): preserve $ in OData query params; reject empty items array - buildOdataUrl now constructs query strings manually with encodeURIComponent and restores literal "$" so OData system options ($filter, $top, $select, $expand, $orderby, $skip, $format) reach SAP and any intermediary proxies/WAFs as-is, not as "%24filter". URLSearchParams was percent-encoding "$" to "%24" which most ICMs decode but some intermediaries silently drop, returning unfiltered results. - create_sales_order now rejects an empty items array (matches create_purchase_requisition) so callers get a clear client-side error instead of an opaque SAP validation failure on the deep-insert. Co-Authored-By: Claude Opus 4.7 * fix(sap_s4hana): ignore baseUrl on cloud_public to prevent token redirection Why: resolveHost previously preferred baseUrl unconditionally. A caller sending deploymentType=cloud_public with a baseUrl pointing elsewhere would obtain a real SAP UAA token, then forward it as Bearer to the attacker host. Zod superRefine did not validate baseUrl for cloud_public. Fix: resolveHost now constructs the SAP host from subdomain when deploymentType is cloud_public and only uses baseUrl for cloud_private and on_premise (where it is already SSRF-checked in superRefine). Co-Authored-By: Claude Opus 4.7 * fix(icons): use useId for SapS4HanaIcon and PipedriveIcon gradients Why: hardcoded SVG gradient/mask IDs collide when an icon renders more than once on a page (e.g. integrations listing). All other icons in this file use React's useId() — these were inconsistent. Co-Authored-By: Claude Opus 4.7 * icons * fix(icons): use useId for AWS-style icon gradients Why: IAMIcon, IdentityCenterIcon, STSIcon, SESIcon, and SecretsManagerIcon all used hardcoded `id='xxxGradient'` values that collide when an icon renders more than once on a page (e.g. integrations listing). Co-Authored-By: Claude Opus 4.7 * fix(sap_s4hana): ignore tokenUrl on cloud_public to prevent UAA redirection Why: resolveTokenUrl previously honored caller-supplied tokenUrl regardless of deploymentType, mirroring the same redirection class as the prior baseUrl bug. A cloud_public caller could send tokenUrl to an attacker host, causing the proxy to POST clientId:clientSecret as Basic auth to it. superRefine for cloud_public did not validate tokenUrl. Fix: derive UAA URL from subdomain+region for cloud_public; only honor tokenUrl for cloud_private/on_premise (already SSRF-checked). Co-Authored-By: Claude Opus 4.7 * fix(icons): remove unused mask in PipedriveIcon Why: the element had no consumer (no mask='url(#...)' anywhere in the SVG), so both it and the maskId variable were dead code. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- apps/docs/components/icons.tsx | 67 +- apps/docs/components/ui/icon-mapping.ts | 2 + apps/docs/content/docs/en/tools/meta.json | 1 + .../docs/content/docs/en/tools/sap_s4hana.mdx | 1182 +++++++++++++++++ .../integrations/data/icon-mapping.ts | 2 + .../integrations/data/integrations.json | 167 +++ .../app/api/tools/sap_s4hana/proxy/route.ts | 614 +++++++++ apps/sim/blocks/blocks/sap_s4hana.ts | 1144 ++++++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 67 +- apps/sim/tools/registry.ts | 76 ++ .../sap_s4hana/create_business_partner.ts | 162 +++ .../tools/sap_s4hana/create_purchase_order.ts | 145 ++ .../sap_s4hana/create_purchase_requisition.ts | 132 ++ .../tools/sap_s4hana/create_sales_order.ts | 159 +++ .../tools/sap_s4hana/delete_sales_order.ts | 108 ++ .../tools/sap_s4hana/get_billing_document.ts | 115 ++ .../tools/sap_s4hana/get_business_partner.ts | 115 ++ apps/sim/tools/sap_s4hana/get_customer.ts | 116 ++ .../tools/sap_s4hana/get_inbound_delivery.ts | 116 ++ .../tools/sap_s4hana/get_outbound_delivery.ts | 116 ++ apps/sim/tools/sap_s4hana/get_product.ts | 115 ++ .../tools/sap_s4hana/get_purchase_order.ts | 115 ++ .../sap_s4hana/get_purchase_requisition.ts | 118 ++ apps/sim/tools/sap_s4hana/get_sales_order.ts | 115 ++ apps/sim/tools/sap_s4hana/get_supplier.ts | 116 ++ .../tools/sap_s4hana/get_supplier_invoice.ts | 121 ++ apps/sim/tools/sap_s4hana/index.ts | 37 + .../sap_s4hana/list_billing_documents.ts | 132 ++ .../sap_s4hana/list_business_partners.ts | 132 ++ apps/sim/tools/sap_s4hana/list_customers.ts | 133 ++ .../sap_s4hana/list_inbound_deliveries.ts | 134 ++ .../sap_s4hana/list_material_documents.ts | 135 ++ .../tools/sap_s4hana/list_material_stock.ts | 133 ++ .../sap_s4hana/list_outbound_deliveries.ts | 136 ++ apps/sim/tools/sap_s4hana/list_products.ts | 132 ++ .../tools/sap_s4hana/list_purchase_orders.ts | 132 ++ .../sap_s4hana/list_purchase_requisitions.ts | 135 ++ .../sim/tools/sap_s4hana/list_sales_orders.ts | 132 ++ .../sap_s4hana/list_supplier_invoices.ts | 132 ++ apps/sim/tools/sap_s4hana/list_suppliers.ts | 133 ++ apps/sim/tools/sap_s4hana/odata_query.ts | 163 +++ apps/sim/tools/sap_s4hana/types.ts | 302 +++++ .../sap_s4hana/update_business_partner.ts | 128 ++ apps/sim/tools/sap_s4hana/update_customer.ts | 127 ++ apps/sim/tools/sap_s4hana/update_product.ts | 127 ++ .../tools/sap_s4hana/update_purchase_order.ts | 127 ++ .../sap_s4hana/update_purchase_requisition.ts | 131 ++ .../tools/sap_s4hana/update_sales_order.ts | 127 ++ apps/sim/tools/sap_s4hana/update_supplier.ts | 127 ++ apps/sim/tools/sap_s4hana/utils.ts | 90 ++ 51 files changed, 8495 insertions(+), 30 deletions(-) create mode 100644 apps/docs/content/docs/en/tools/sap_s4hana.mdx create mode 100644 apps/sim/app/api/tools/sap_s4hana/proxy/route.ts create mode 100644 apps/sim/blocks/blocks/sap_s4hana.ts create mode 100644 apps/sim/tools/sap_s4hana/create_business_partner.ts create mode 100644 apps/sim/tools/sap_s4hana/create_purchase_order.ts create mode 100644 apps/sim/tools/sap_s4hana/create_purchase_requisition.ts create mode 100644 apps/sim/tools/sap_s4hana/create_sales_order.ts create mode 100644 apps/sim/tools/sap_s4hana/delete_sales_order.ts create mode 100644 apps/sim/tools/sap_s4hana/get_billing_document.ts create mode 100644 apps/sim/tools/sap_s4hana/get_business_partner.ts create mode 100644 apps/sim/tools/sap_s4hana/get_customer.ts create mode 100644 apps/sim/tools/sap_s4hana/get_inbound_delivery.ts create mode 100644 apps/sim/tools/sap_s4hana/get_outbound_delivery.ts create mode 100644 apps/sim/tools/sap_s4hana/get_product.ts create mode 100644 apps/sim/tools/sap_s4hana/get_purchase_order.ts create mode 100644 apps/sim/tools/sap_s4hana/get_purchase_requisition.ts create mode 100644 apps/sim/tools/sap_s4hana/get_sales_order.ts create mode 100644 apps/sim/tools/sap_s4hana/get_supplier.ts create mode 100644 apps/sim/tools/sap_s4hana/get_supplier_invoice.ts create mode 100644 apps/sim/tools/sap_s4hana/index.ts create mode 100644 apps/sim/tools/sap_s4hana/list_billing_documents.ts create mode 100644 apps/sim/tools/sap_s4hana/list_business_partners.ts create mode 100644 apps/sim/tools/sap_s4hana/list_customers.ts create mode 100644 apps/sim/tools/sap_s4hana/list_inbound_deliveries.ts create mode 100644 apps/sim/tools/sap_s4hana/list_material_documents.ts create mode 100644 apps/sim/tools/sap_s4hana/list_material_stock.ts create mode 100644 apps/sim/tools/sap_s4hana/list_outbound_deliveries.ts create mode 100644 apps/sim/tools/sap_s4hana/list_products.ts create mode 100644 apps/sim/tools/sap_s4hana/list_purchase_orders.ts create mode 100644 apps/sim/tools/sap_s4hana/list_purchase_requisitions.ts create mode 100644 apps/sim/tools/sap_s4hana/list_sales_orders.ts create mode 100644 apps/sim/tools/sap_s4hana/list_supplier_invoices.ts create mode 100644 apps/sim/tools/sap_s4hana/list_suppliers.ts create mode 100644 apps/sim/tools/sap_s4hana/odata_query.ts create mode 100644 apps/sim/tools/sap_s4hana/types.ts create mode 100644 apps/sim/tools/sap_s4hana/update_business_partner.ts create mode 100644 apps/sim/tools/sap_s4hana/update_customer.ts create mode 100644 apps/sim/tools/sap_s4hana/update_product.ts create mode 100644 apps/sim/tools/sap_s4hana/update_purchase_order.ts create mode 100644 apps/sim/tools/sap_s4hana/update_purchase_requisition.ts create mode 100644 apps/sim/tools/sap_s4hana/update_sales_order.ts create mode 100644 apps/sim/tools/sap_s4hana/update_supplier.ts create mode 100644 apps/sim/tools/sap_s4hana/utils.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 35a34a66777..4f47786f28a 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -4045,6 +4045,7 @@ export function AsanaIcon(props: SVGProps) { } export function PipedriveIcon(props: SVGProps) { + const pathId = useId() return ( ) { ) { fillRule='evenodd' > - - - - + @@ -4098,6 +4096,40 @@ export function SalesforceIcon(props: SVGProps) { ) } +export function SapS4HanaIcon(props: SVGProps) { + const id = useId() + return ( + + + + + + + + + + + + + + ) +} + export function ServiceNowIcon(props: SVGProps) { return ( @@ -4694,15 +4726,16 @@ export function DynamoDBIcon(props: SVGProps) { } export function IAMIcon(props: SVGProps) { + const id = useId() return ( - + - + ) { } export function IdentityCenterIcon(props: SVGProps) { + const id = useId() return ( - + - + ) { } export function STSIcon(props: SVGProps) { + const id = useId() return ( - + - + ) { } export function SESIcon(props: SVGProps) { + const id = useId() return ( - + - + ) { } export function SecretsManagerIcon(props: SVGProps) { + const id = useId() return ( - + - + = { rootly: RootlyIcon, s3: S3Icon, salesforce: SalesforceIcon, + sap_s4hana: SapS4HanaIcon, search: SearchIcon, secrets_manager: SecretsManagerIcon, sendgrid: SendgridIcon, diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index f7ce46bd766..1f780cff3d2 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -150,6 +150,7 @@ "rootly", "s3", "salesforce", + "sap_s4hana", "search", "secrets_manager", "sendgrid", diff --git a/apps/docs/content/docs/en/tools/sap_s4hana.mdx b/apps/docs/content/docs/en/tools/sap_s4hana.mdx new file mode 100644 index 00000000000..1647bf5f706 --- /dev/null +++ b/apps/docs/content/docs/en/tools/sap_s4hana.mdx @@ -0,0 +1,1182 @@ +--- +title: SAP S/4HANA +description: Read and write SAP S/4HANA Cloud business data via OData +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[SAP S/4HANA](https://www.sap.com/products/erp/s4hana.html) is SAP's flagship intelligent ERP suite, running on the in-memory HANA database. It powers finance, supply chain, procurement, sales, and manufacturing for organizations of every size, and exposes its business data through a broad catalog of OData services on SAP Business Technology Platform (BTP). + +With SAP S/4HANA, you can: + +- **Run core business processes**: Manage finance, procurement, sales, logistics, inventory, and manufacturing on a single source of truth. +- **Model master data at scale**: Maintain business partners, customers, suppliers, products, and organizational structures across multiple company codes, sales organizations, and plants. +- **Execute transactional flows end to end**: Create and update sales orders, purchase requisitions, purchase orders, deliveries, billing documents, supplier invoices, and stock movements with full audit trails. +- **Govern access cleanly**: Use Communication Arrangements, Communication Systems, and Communication Scopes to scope OAuth client credentials to exactly the services each integration needs. +- **Integrate via standard OData**: Every entity supported here speaks OData v2 with consistent paging, filtering, expansion, and ETag-based optimistic concurrency. + +In Sim, the SAP S/4HANA integration lets your agents read and write directly against your tenant's OData services using per-tenant OAuth 2.0 client credentials. Agents can list and fetch master data, create and update transactional documents, run stock and material document queries, and execute arbitrary OData v2 calls against any whitelisted Communication Scenario — all routed through a single internal proxy that handles token acquisition, CSRF fetch-and-retry, and OData error normalization. Use it to automate order-to-cash, procure-to-pay, and inventory workflows, keep SAP in sync with the rest of your stack, or trigger downstream agent logic from SAP business events. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +{/* MANUAL-CONTENT-START:usage */} +Connect any SAP S/4HANA tenant — **Cloud Public Edition**, **Cloud Private Edition (RISE)**, or **on-premise** — and read or write business data through the official OData v2 services. Each tool routes through a single internal proxy that handles token acquisition, CSRF fetch-and-retry for write operations, and OData error normalization. + +### Deployment modes + +Pick the deployment that matches your tenant in the **Deployment** dropdown: + +- **S/4HANA Cloud Public Edition** — provide your **BTP subaccount subdomain** and **region** (e.g., `eu10`, `us10`). The host is derived automatically as `{subdomain}-api.s4hana.ondemand.com`, and OAuth tokens are fetched from the matching BTP UAA endpoint. Authentication is OAuth 2.0 client credentials configured in a Communication Arrangement. +- **S/4HANA Cloud Private Edition (RISE)** — provide your **OData Base URL** (e.g., `https://my-tenant.s4hana.cloud.sap`). Authenticate with **OAuth 2.0 client credentials** (provide the tenant's UAA `tokenUrl`, `clientId`, `clientSecret`) or **HTTP Basic** with a Communication User (`username`, `password`). +- **On-premise S/4HANA** — provide your **OData Base URL** (e.g., `https://sap.internal.company.com:44300`). Authenticate with **OAuth 2.0 client credentials** issued by your on-prem identity provider, or **HTTP Basic** with a service user. + +### What you can do + +Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices. Update business partners, customers, suppliers, products, sales orders, purchase orders, and purchase requisitions with PATCH. Run arbitrary OData v2 queries against any whitelisted Communication Scenario or registered service. + +### Optimistic concurrency + +All update tools accept an optional `ifMatch` ETag. When omitted, `If-Match` defaults to a wildcard (unconditional). For safe concurrent updates, fetch the entity first, capture its ETag from the response, and pass it as `ifMatch` to detect lost updates. +{/* MANUAL-CONTENT-END */} + + +Connect SAP S/4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario. + + + +## Tools + +### `sap_s4hana_list_business_partners` + +List business partners from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_BusinessPartner) with optional OData $filter, $top, $skip, $orderby, $select, $expand. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `filter` | string | No | OData $filter expression \(e.g., "BusinessPartnerCategory eq \'1\'"\) | +| `top` | number | No | Maximum results to return \($top\) | +| `skip` | number | No | Number of results to skip \($skip\) | +| `orderBy` | string | No | OData $orderby expression | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \($expand\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Array of A_BusinessPartner entities | + +### `sap_s4hana_get_business_partner` + +Retrieve a single business partner by BusinessPartner key from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_BusinessPartner). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `businessPartner` | string | Yes | BusinessPartner key \(string, up to 10 characters\) | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \($expand\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | A_BusinessPartner entity | + +### `sap_s4hana_create_business_partner` + +Create a business partner in SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_BusinessPartner). For Person category 1 provide FirstName and LastName. For Organization category 2 provide OrganizationBPName1. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `businessPartnerCategory` | string | Yes | BusinessPartnerCategory: "1" Person, "2" Organization, "3" Group | +| `businessPartnerGrouping` | string | Yes | BusinessPartnerGrouping \(number range / role grouping configured in S/4HANA, e.g. "0001"\) | +| `firstName` | string | No | FirstName \(required for Person\) | +| `lastName` | string | No | LastName \(required for Person\) | +| `organizationBPName1` | string | No | OrganizationBPName1 \(required for Organization\) | +| `body` | json | No | Optional additional A_BusinessPartner fields merged into the create payload | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Created A_BusinessPartner entity | + +### `sap_s4hana_update_business_partner` + +Update fields on an A_BusinessPartner entity in SAP S/4HANA Cloud (API_BUSINESS_PARTNER). PATCH only sends the fields you provide; existing values are preserved. If-Match defaults to a wildcard (unconditional) — for safe concurrent updates pass the ETag from a prior GET to avoid lost updates. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `businessPartner` | string | Yes | BusinessPartner key to update \(string, up to 10 characters\) | +| `body` | json | Yes | JSON object with A_BusinessPartner fields to update \(e.g., \{"FirstName":"Jane","SearchTerm1":"VIP"\}\) | +| `ifMatch` | string | No | If-Match ETag for optimistic concurrency. Defaults to "*" \(unconditional\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP \(204 on success\) | +| `data` | json | Null on 204 success, or updated A_BusinessPartner entity if SAP returns one | + +### `sap_s4hana_list_customers` + +List customers from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_Customer) with optional OData $filter, $top, $skip, $orderby, $select, $expand. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `filter` | string | No | OData $filter expression \(e.g., "CustomerAccountGroup eq \'Z001\'"\) | +| `top` | number | No | Maximum results to return \($top\) | +| `skip` | number | No | Number of results to skip \($skip\) | +| `orderBy` | string | No | OData $orderby expression | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_CustomerCompany,to_CustomerSalesArea"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Array of A_Customer entities | + +### `sap_s4hana_get_customer` + +Retrieve a single customer by Customer key from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_Customer). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `customer` | string | Yes | Customer key \(string, up to 10 characters\) | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_CustomerCompany,to_CustomerSalesArea"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | A_Customer entity | + +### `sap_s4hana_update_customer` + +Update fields on an A_Customer entity in SAP S/4HANA Cloud (API_BUSINESS_PARTNER). PATCH only sends the fields you provide; existing values are preserved. A_Customer PATCH is limited to modifiable fields such as OrderIsBlockedForCustomer, DeliveryIsBlock, BillingIsBlockedForCustomer, PostingIsBlocked, and DeletionIndicator. If-Match defaults to a wildcard - for safe concurrent updates pass the ETag from a prior GET to avoid lost updates. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `customer` | string | Yes | Customer key to update \(string, up to 10 characters\) | +| `body` | json | Yes | JSON object with A_Customer fields to update \(e.g., \{"OrderIsBlockedForCustomer":true,"DeletionIndicator":false\}\) | +| `ifMatch` | string | No | If-Match ETag for optimistic concurrency. Defaults to "*" \(unconditional\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP \(204 on success\) | +| `data` | json | Null on 204 success, or updated A_Customer entity if SAP returns one | + +### `sap_s4hana_list_suppliers` + +List suppliers from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_Supplier) with optional OData $filter, $top, $skip, $orderby, $select, $expand. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `filter` | string | No | OData $filter expression \(e.g., "SupplierAccountGroup eq \'BP02\'"\) | +| `top` | number | No | Maximum results to return \($top\) | +| `skip` | number | No | Number of results to skip \($skip\) | +| `orderBy` | string | No | OData $orderby expression | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_SupplierCompany,to_SupplierPurchasingOrg"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Array of A_Supplier entities | + +### `sap_s4hana_get_supplier` + +Retrieve a single supplier by Supplier key from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_Supplier). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `supplier` | string | Yes | Supplier key \(string, up to 10 characters\) | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_SupplierCompany,to_SupplierPurchasingOrg"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | A_Supplier entity | + +### `sap_s4hana_update_supplier` + +Update fields on an A_Supplier entity in SAP S/4HANA Cloud (API_BUSINESS_PARTNER). PATCH only sends the fields you provide; existing values are preserved. A_Supplier PATCH is limited to modifiable fields such as PostingIsBlocked, PurchasingIsBlocked, PaymentIsBlockedForSupplier, DeletionIndicator, and SupplierAccountGroup. If-Match defaults to a wildcard - for safe concurrent updates pass the ETag from a prior GET to avoid lost updates. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `supplier` | string | Yes | Supplier key to update \(string, up to 10 characters\) | +| `body` | json | Yes | JSON object with A_Supplier fields to update \(e.g., \{"PaymentIsBlockedForSupplier":true,"PostingIsBlocked":true\}\) | +| `ifMatch` | string | No | If-Match ETag for optimistic concurrency. Defaults to "*" \(unconditional\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP \(204 on success\) | +| `data` | json | Null on 204 success, or updated A_Supplier entity if SAP returns one | + +### `sap_s4hana_list_sales_orders` + +List sales orders from SAP S/4HANA Cloud (API_SALES_ORDER_SRV, A_SalesOrder) with optional OData $filter, $top, $skip, $orderby, $select, $expand. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `filter` | string | No | OData $filter expression \(e.g., "SalesOrganization eq \'1010\'"\) | +| `top` | number | No | Maximum results to return \($top\) | +| `skip` | number | No | Number of results to skip \($skip\) | +| `orderBy` | string | No | OData $orderby expression | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_Item,to_Partner"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Array of A_SalesOrder entities | + +### `sap_s4hana_get_sales_order` + +Retrieve a single sales order by SalesOrder key from SAP S/4HANA Cloud (API_SALES_ORDER_SRV, A_SalesOrder). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `salesOrder` | string | Yes | SalesOrder key \(string, up to 10 characters\) | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_Item"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | A_SalesOrder entity | + +### `sap_s4hana_create_sales_order` + +Create a sales order in SAP S/4HANA Cloud (API_SALES_ORDER_SRV, A_SalesOrder) with deep insert of sales order items via to_Item. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `salesOrderType` | string | Yes | SalesOrderType \(e.g., "OR" Standard Order\) | +| `salesOrganization` | string | Yes | SalesOrganization \(4 chars, e.g., "1010"\) | +| `distributionChannel` | string | Yes | DistributionChannel \(2 chars, e.g., "10"\) | +| `organizationDivision` | string | Yes | OrganizationDivision \(2 chars, e.g., "00"\) | +| `soldToParty` | string | Yes | SoldToParty business partner key \(up to 10 chars\) | +| `items` | json | Yes | Array of sales order items for to_Item deep insert. Each item should include Material and RequestedQuantity \(e.g., \[\{"Material":"TG11","RequestedQuantity":"1"\}\]\). | +| `body` | json | No | Optional additional A_SalesOrder fields merged into the create payload | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Created A_SalesOrder entity \(with deep-inserted items if expanded by SAP\) | + +### `sap_s4hana_update_sales_order` + +Update fields on an A_SalesOrder entity in SAP S/4HANA Cloud (API_SALES_ORDER_SRV). PATCH only sends the fields you provide; existing values are preserved. If-Match defaults to a wildcard (unconditional) — for safe concurrent updates pass the ETag from a prior GET to avoid lost updates. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `salesOrder` | string | Yes | SalesOrder key to update \(string, up to 10 characters\) | +| `body` | json | Yes | JSON object with A_SalesOrder fields to update \(e.g., \{"PurchaseOrderByCustomer":"PO-12345","HeaderBillingBlockReason":"01"\}\) | +| `ifMatch` | string | No | If-Match ETag for optimistic concurrency. Defaults to "*" \(unconditional\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP \(204 on success\) | +| `data` | json | Null on 204 success, or updated A_SalesOrder entity if SAP returns one | + +### `sap_s4hana_delete_sales_order` + +Delete an A_SalesOrder entity in SAP S/4HANA Cloud (API_SALES_ORDER_SRV). Only orders without subsequent documents (deliveries, invoices) can be deleted; otherwise reject items via update instead. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `salesOrder` | string | Yes | SalesOrder key to delete \(string, up to 10 characters\) | +| `ifMatch` | string | No | If-Match ETag for optimistic concurrency. Defaults to "*" \(unconditional\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP \(204 on success\) | +| `data` | json | Null on successful deletion | + +### `sap_s4hana_list_outbound_deliveries` + +List outbound deliveries from SAP S/4HANA Cloud (API_OUTBOUND_DELIVERY_SRV;v=0002, A_OutbDeliveryHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `filter` | string | No | OData $filter expression \(e.g., "OverallDeliveryStatus eq \'C\'"\) | +| `top` | number | No | Maximum results to return \($top\) | +| `skip` | number | No | Number of results to skip \($skip\) | +| `orderBy` | string | No | OData $orderby expression | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_DeliveryDocumentItem"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Array of A_OutbDeliveryHeader entities | + +### `sap_s4hana_get_outbound_delivery` + +Retrieve a single outbound delivery by DeliveryDocument key from SAP S/4HANA Cloud (API_OUTBOUND_DELIVERY_SRV;v=0002, A_OutbDeliveryHeader). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `deliveryDocument` | string | Yes | DeliveryDocument key \(string, up to 10 characters\) | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_DeliveryDocumentItem"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | A_OutbDeliveryHeader entity | + +### `sap_s4hana_list_inbound_deliveries` + +List inbound deliveries from SAP S/4HANA Cloud (API_INBOUND_DELIVERY_SRV;v=0002, A_InbDeliveryHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `filter` | string | No | OData $filter expression \(e.g., "ReceivingPlant eq \'1010\'"\) | +| `top` | number | No | Maximum results to return \($top\) | +| `skip` | number | No | Number of results to skip \($skip\) | +| `orderBy` | string | No | OData $orderby expression | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_DeliveryDocumentItem"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Array of A_InbDeliveryHeader entities | + +### `sap_s4hana_get_inbound_delivery` + +Retrieve a single inbound delivery by DeliveryDocument key from SAP S/4HANA Cloud (API_INBOUND_DELIVERY_SRV;v=0002, A_InbDeliveryHeader). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `deliveryDocument` | string | Yes | DeliveryDocument key \(string, up to 10 characters\) | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_DeliveryDocumentItem"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | A_InbDeliveryHeader entity | + +### `sap_s4hana_list_billing_documents` + +List billing documents (customer invoices) from SAP S/4HANA Cloud (API_BILLING_DOCUMENT_SRV, A_BillingDocument) with optional OData $filter, $top, $skip, $orderby, $select, $expand. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `filter` | string | No | OData $filter expression \(e.g., "SoldToParty eq \'10100001\'"\) | +| `top` | number | No | Maximum results to return \($top\) | +| `skip` | number | No | Number of results to skip \($skip\) | +| `orderBy` | string | No | OData $orderby expression | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_Item,to_Partner"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Array of A_BillingDocument entities | + +### `sap_s4hana_get_billing_document` + +Retrieve a single billing document (customer invoice) by BillingDocument key from SAP S/4HANA Cloud (API_BILLING_DOCUMENT_SRV, A_BillingDocument). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `billingDocument` | string | Yes | BillingDocument key \(string, up to 10 characters\) | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_Item,to_Partner"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | A_BillingDocument entity | + +### `sap_s4hana_list_products` + +List products (materials) from SAP S/4HANA Cloud (API_PRODUCT_SRV, A_Product) with optional OData $filter, $top, $skip, $orderby, $select, $expand. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `filter` | string | No | OData $filter expression \(e.g., "ProductType eq \'FERT\'"\) | +| `top` | number | No | Maximum results to return \($top\) | +| `skip` | number | No | Number of results to skip \($skip\) | +| `orderBy` | string | No | OData $orderby expression | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \($expand\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Array of A_Product entities | + +### `sap_s4hana_get_product` + +Retrieve a single product (material) by Product key from SAP S/4HANA Cloud (API_PRODUCT_SRV, A_Product). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `product` | string | Yes | Product key \(string, up to 40 characters\) | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_Description"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | A_Product entity | + +### `sap_s4hana_update_product` + +Update fields on an A_Product entity in SAP S/4HANA Cloud (API_PRODUCT_SRV). PATCH only sends the fields you provide; existing values are preserved. Flat scalar header fields only — deep/multi-entity updates across navigation properties are not supported by API_PRODUCT_SRV PATCH/PUT (see SAP KBA 2833338); update child entities (plant, valuation, sales data, etc.) via their own endpoints. If-Match defaults to a wildcard (unconditional) — for safe concurrent updates pass the ETag from a prior GET. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `product` | string | Yes | Product key to update \(string, up to 40 characters\) | +| `body` | json | Yes | JSON object with A_Product fields to update \(e.g., \{"ProductGroup":"L001","IsMarkedForDeletion":false\}\) | +| `ifMatch` | string | No | If-Match ETag for optimistic concurrency. Defaults to "*" \(unconditional\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP \(204 on success\) | +| `data` | json | Null on 204 success, or updated A_Product entity if SAP returns one | + +### `sap_s4hana_list_material_stock` + +List material stock quantities from SAP S/4HANA Cloud (API_MATERIAL_STOCK_SRV, A_MatlStkInAcctMod). The entity uses an 11-field composite key (Material, Plant, StorageLocation, Batch, Supplier, Customer, WBSElementInternalID, SDDocument, SDDocumentItem, InventorySpecialStockType, InventoryStockType) — query with $filter on these fields instead of a direct key lookup. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `filter` | string | No | OData $filter expression \(e.g., \"Material eq 'TG10' and Plant eq '1010' and InventoryStockType eq '01'\"\) | +| `top` | number | No | Maximum results to return \($top\) | +| `skip` | number | No | Number of results to skip \($skip\) | +| `orderBy` | string | No | OData $orderby expression | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \($expand\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Array of A_MatlStkInAcctMod stock entries | + +### `sap_s4hana_list_material_documents` + +List material document headers (goods movements) from SAP S/4HANA Cloud (API_MATERIAL_DOCUMENT_SRV, A_MaterialDocumentHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `filter` | string | No | OData $filter expression \(e.g., \"MaterialDocumentYear eq '2024' and PostingDate ge datetime'2024-01-01T00:00:00'\"\) | +| `top` | number | No | Maximum results to return \($top\) | +| `skip` | number | No | Number of results to skip \($skip\) | +| `orderBy` | string | No | OData $orderby expression | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_MaterialDocumentItem"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Array of A_MaterialDocumentHeader entities | + +### `sap_s4hana_list_purchase_requisitions` + +List purchase requisitions from SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV, A_PurchaseRequisitionHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand. Note: API_PURCHASEREQ_PROCESS_SRV is deprecated since S/4HANA Cloud Public Edition 2402; the successor is API_PURCHASEREQUISITION_2 (OData v4). This tool still works against tenants where the legacy service is enabled. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `filter` | string | No | OData $filter expression \(e.g., "PurchaseRequisitionType eq \'NB\'"\) | +| `top` | number | No | Maximum results to return \($top\) | +| `skip` | number | No | Number of results to skip \($skip\) | +| `orderBy` | string | No | OData $orderby expression | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_PurchaseReqnItem"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Array of A_PurchaseRequisitionHeader entities | + +### `sap_s4hana_get_purchase_requisition` + +Retrieve a single purchase requisition by PurchaseRequisition key from SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV, A_PurchaseRequisitionHeader). Note: API_PURCHASEREQ_PROCESS_SRV is deprecated since S/4HANA Cloud Public Edition 2402; the successor is API_PURCHASEREQUISITION_2 (OData v4). This tool still works against tenants where the legacy service is enabled. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `purchaseRequisition` | string | Yes | PurchaseRequisition key \(string, up to 10 characters\) | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_PurchaseReqnItem"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | A_PurchaseRequisitionHeader entity | + +### `sap_s4hana_create_purchase_requisition` + +Create a purchase requisition in SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV, A_PurchaseRequisitionHeader). PurchaseRequisition is auto-assigned by SAP from the document number range; provide line items via the to_PurchaseReqnItem deep-insert array. Note: API_PURCHASEREQ_PROCESS_SRV is deprecated since S/4HANA Cloud Public Edition 2402; the successor is API_PURCHASEREQUISITION_2 (OData v4). This tool still works against tenants where the legacy service is enabled. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `purchaseRequisitionType` | string | Yes | PurchaseRequisitionType \(e.g., "NB" Standard PR\) | +| `items` | json | Yes | to_PurchaseReqnItem deep-insert array \(e.g., \[\{"PurchaseRequisitionItem":"10","Material":"TG11","RequestedQuantity":"5","Plant":"1010","BaseUnit":"PC","DeliveryDate":"/Date\(1735689600000\)/"\}\]\) | +| `body` | json | No | Additional A_PurchaseRequisitionHeader fields merged into the create payload \(e.g., \{"PurchaseRequisitionDescription":"Office supplies"\}\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Created A_PurchaseRequisitionHeader entity | + +### `sap_s4hana_update_purchase_requisition` + +Update fields on an A_PurchaseRequisitionHeader entity in SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV; deprecated since S/4HANA 2402, successor is API_PURCHASEREQUISITION_2 OData v4). PATCH only sends the fields you provide; existing values are preserved. If-Match defaults to a wildcard - for safe concurrent updates pass the ETag from a prior GET to avoid lost updates. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `purchaseRequisition` | string | Yes | PurchaseRequisition key to update \(string, up to 10 characters\) | +| `body` | json | Yes | JSON object with A_PurchaseRequisitionHeader fields to update \(e.g., \{"PurchaseRequisitionType":"NB"\}\) | +| `ifMatch` | string | No | If-Match ETag for optimistic concurrency. Defaults to "*" \(unconditional\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP \(204 on success\) | +| `data` | json | Null on 204 success, or updated A_PurchaseRequisitionHeader entity if SAP returns one | + +### `sap_s4hana_list_purchase_orders` + +List purchase orders from SAP S/4HANA Cloud (API_PURCHASEORDER_PROCESS_SRV, A_PurchaseOrder) with optional OData $filter, $top, $skip, $orderby, $select, $expand. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `filter` | string | No | OData $filter expression \(e.g., "CompanyCode eq \'1010\'"\) | +| `top` | number | No | Maximum results to return \($top\) | +| `skip` | number | No | Number of results to skip \($skip\) | +| `orderBy` | string | No | OData $orderby expression | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_PurchaseOrderItem"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Array of A_PurchaseOrder entities | + +### `sap_s4hana_get_purchase_order` + +Retrieve a single purchase order by PurchaseOrder key from SAP S/4HANA Cloud (API_PURCHASEORDER_PROCESS_SRV, A_PurchaseOrder). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `purchaseOrder` | string | Yes | PurchaseOrder key \(string, up to 10 characters\) | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_PurchaseOrderItem"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | A_PurchaseOrder entity | + +### `sap_s4hana_create_purchase_order` + +Create a purchase order in SAP S/4HANA Cloud (API_PURCHASEORDER_PROCESS_SRV, A_PurchaseOrder). PurchaseOrder is auto-assigned by SAP from the document number range; provide line items via the body parameter. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `purchaseOrderType` | string | Yes | PurchaseOrderType \(e.g., "NB" Standard PO\) | +| `companyCode` | string | Yes | CompanyCode \(4 chars, e.g., "1010"\) | +| `purchasingOrganization` | string | Yes | PurchasingOrganization \(4 chars\) | +| `purchasingGroup` | string | Yes | PurchasingGroup \(3 chars\) | +| `supplier` | string | Yes | Supplier business partner key \(up to 10 chars\) | +| `body` | json | No | Additional A_PurchaseOrder fields and to_PurchaseOrderItem deep-insert items merged into the create payload \(e.g., \{"to_PurchaseOrderItem":\[\{"PurchaseOrderItem":"10","Material":"TG11","OrderQuantity":"5","Plant":"1010","PurchaseOrderQuantityUnit":"PC","NetPriceAmount":"100.00","DocumentCurrency":"USD"\}\]\}\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Created A_PurchaseOrder entity | + +### `sap_s4hana_update_purchase_order` + +Update fields on an A_PurchaseOrder entity in SAP S/4HANA Cloud (API_PURCHASEORDER_PROCESS_SRV). PATCH only sends the fields you provide; existing values are preserved. If-Match defaults to a wildcard (unconditional) — for safe concurrent updates pass the ETag from a prior GET to avoid lost updates. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `purchaseOrder` | string | Yes | PurchaseOrder key to update \(string, up to 10 characters\) | +| `body` | json | Yes | JSON object with A_PurchaseOrder fields to update \(e.g., \{"PurchasingGroup":"002","PurchaseOrderDate":"/Date\(1735689600000\)/"\}\) | +| `ifMatch` | string | No | If-Match ETag for optimistic concurrency. Defaults to "*" \(unconditional\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP \(204 on success\) | +| `data` | json | Null on 204 success, or updated A_PurchaseOrder entity if SAP returns one | + +### `sap_s4hana_list_supplier_invoices` + +List supplier invoices from SAP S/4HANA Cloud (API_SUPPLIERINVOICE_PROCESS_SRV, A_SupplierInvoice) with optional OData $filter, $top, $skip, $orderby, $select, $expand. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `filter` | string | No | OData $filter expression \(e.g., "InvoicingParty eq \'17300001\'"\) | +| `top` | number | No | Maximum results to return \($top\) | +| `skip` | number | No | Number of results to skip \($skip\) | +| `orderBy` | string | No | OData $orderby expression | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \($expand\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Array of A_SupplierInvoice entities | + +### `sap_s4hana_get_supplier_invoice` + +Retrieve a single supplier invoice by composite key (SupplierInvoice + FiscalYear) from SAP S/4HANA Cloud (API_SUPPLIERINVOICE_PROCESS_SRV, A_SupplierInvoice). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `supplierInvoice` | string | Yes | SupplierInvoice key \(string, up to 10 characters\) | +| `fiscalYear` | string | Yes | FiscalYear \(4-character year, e.g., "2024"\) | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \($expand\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | A_SupplierInvoice entity | + +### `sap_s4hana_odata_query` + +Make an arbitrary OData v2 call against any SAP S/4HANA Cloud whitelisted Communication Scenario. Use when no dedicated tool exists for the entity. The proxy handles auth, CSRF, and OData unwrapping. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `service` | string | Yes | OData service name \(e.g., "API_BUSINESS_PARTNER", "API_SALES_ORDER_SRV"\) | +| `path` | string | Yes | Path inside the service \(e.g., "/A_BusinessPartner" or "/A_BusinessPartner\(\'1000123\'\)"\) | +| `method` | string | No | HTTP method: GET \(default\), POST, PATCH, PUT, DELETE, MERGE | +| `query` | json | No | OData query parameters as JSON object or query string \(e.g., \{"$filter":"BusinessPartnerCategory eq \'1\'","$top":10\}\). $format=json is added automatically when omitted. | +| `body` | json | No | JSON request body for write operations | +| `ifMatch` | string | No | ETag value for the If-Match header \(required by SAP for PATCH/PUT/DELETE on existing entities\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | Parsed OData payload \(entity, collection, or null on 204\) | + + diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index 8417728536c..9d3280bd825 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -154,6 +154,7 @@ import { RootlyIcon, S3Icon, SalesforceIcon, + SapS4HanaIcon, SESIcon, SearchIcon, SecretsManagerIcon, @@ -351,6 +352,7 @@ export const blockTypeToIconMap: Record = { rootly: RootlyIcon, s3: S3Icon, salesforce: SalesforceIcon, + sap_s4hana: SapS4HanaIcon, search: SearchIcon, secrets_manager: SecretsManagerIcon, sendgrid: SendgridIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 360f103bbcd..c6bdf4c25c6 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -11379,6 +11379,173 @@ "integrationTypes": ["crm", "customer-support", "sales"], "tags": ["sales-engagement", "customer-support"] }, + { + "type": "sap_s4hana", + "slug": "sap-s-4hana", + "name": "SAP S/4HANA", + "description": "Read and write SAP S/4HANA Cloud business data via OData", + "longDescription": "Connect SAP S/4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario.", + "bgColor": "#0A6ED1", + "iconName": "SapS4HanaIcon", + "docsUrl": "https://docs.sim.ai/tools/sap_s4hana", + "operations": [ + { + "name": "List Business Partners", + "description": "List business partners from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_BusinessPartner) with optional OData $filter, $top, $skip, $orderby, $select, $expand." + }, + { + "name": "Get Business Partner", + "description": "Retrieve a single business partner by BusinessPartner key from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_BusinessPartner)." + }, + { + "name": "Create Business Partner", + "description": "Create a business partner in SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_BusinessPartner). For Person category 1 provide FirstName and LastName. For Organization category 2 provide OrganizationBPName1." + }, + { + "name": "Update Business Partner", + "description": "Update fields on an A_BusinessPartner entity in SAP S/4HANA Cloud (API_BUSINESS_PARTNER). PATCH only sends the fields you provide; existing values are preserved. If-Match defaults to a wildcard (unconditional) — for safe concurrent updates pass the ETag from a prior GET to avoid lost updates." + }, + { + "name": "List Customers", + "description": "List customers from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_Customer) with optional OData $filter, $top, $skip, $orderby, $select, $expand." + }, + { + "name": "Get Customer", + "description": "Retrieve a single customer by Customer key from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_Customer)." + }, + { + "name": "Update Customer", + "description": "Update fields on an A_Customer entity in SAP S/4HANA Cloud (API_BUSINESS_PARTNER). PATCH only sends the fields you provide; existing values are preserved. A_Customer PATCH is limited to modifiable fields such as OrderIsBlockedForCustomer, DeliveryIsBlock, BillingIsBlockedForCustomer, PostingIsBlocked, and DeletionIndicator. If-Match defaults to a wildcard - for safe concurrent updates pass the ETag from a prior GET to avoid lost updates." + }, + { + "name": "List Suppliers", + "description": "List suppliers from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_Supplier) with optional OData $filter, $top, $skip, $orderby, $select, $expand." + }, + { + "name": "Get Supplier", + "description": "Retrieve a single supplier by Supplier key from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_Supplier)." + }, + { + "name": "Update Supplier", + "description": "Update fields on an A_Supplier entity in SAP S/4HANA Cloud (API_BUSINESS_PARTNER). PATCH only sends the fields you provide; existing values are preserved. A_Supplier PATCH is limited to modifiable fields such as PostingIsBlocked, PurchasingIsBlocked, PaymentIsBlockedForSupplier, DeletionIndicator, and SupplierAccountGroup. If-Match defaults to a wildcard - for safe concurrent updates pass the ETag from a prior GET to avoid lost updates." + }, + { + "name": "List Sales Orders", + "description": "List sales orders from SAP S/4HANA Cloud (API_SALES_ORDER_SRV, A_SalesOrder) with optional OData $filter, $top, $skip, $orderby, $select, $expand." + }, + { + "name": "Get Sales Order", + "description": "Retrieve a single sales order by SalesOrder key from SAP S/4HANA Cloud (API_SALES_ORDER_SRV, A_SalesOrder)." + }, + { + "name": "Create Sales Order", + "description": "Create a sales order in SAP S/4HANA Cloud (API_SALES_ORDER_SRV, A_SalesOrder) with deep insert of sales order items via to_Item." + }, + { + "name": "Update Sales Order", + "description": "Update fields on an A_SalesOrder entity in SAP S/4HANA Cloud (API_SALES_ORDER_SRV). PATCH only sends the fields you provide; existing values are preserved. If-Match defaults to a wildcard (unconditional) — for safe concurrent updates pass the ETag from a prior GET to avoid lost updates." + }, + { + "name": "Delete Sales Order", + "description": "Delete an A_SalesOrder entity in SAP S/4HANA Cloud (API_SALES_ORDER_SRV). Only orders without subsequent documents (deliveries, invoices) can be deleted; otherwise reject items via update instead." + }, + { + "name": "List Outbound Deliveries", + "description": "List outbound deliveries from SAP S/4HANA Cloud (API_OUTBOUND_DELIVERY_SRV;v=0002, A_OutbDeliveryHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand." + }, + { + "name": "Get Outbound Delivery", + "description": "Retrieve a single outbound delivery by DeliveryDocument key from SAP S/4HANA Cloud (API_OUTBOUND_DELIVERY_SRV;v=0002, A_OutbDeliveryHeader)." + }, + { + "name": "List Inbound Deliveries", + "description": "List inbound deliveries from SAP S/4HANA Cloud (API_INBOUND_DELIVERY_SRV;v=0002, A_InbDeliveryHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand." + }, + { + "name": "Get Inbound Delivery", + "description": "Retrieve a single inbound delivery by DeliveryDocument key from SAP S/4HANA Cloud (API_INBOUND_DELIVERY_SRV;v=0002, A_InbDeliveryHeader)." + }, + { + "name": "List Billing Documents", + "description": "List billing documents (customer invoices) from SAP S/4HANA Cloud (API_BILLING_DOCUMENT_SRV, A_BillingDocument) with optional OData $filter, $top, $skip, $orderby, $select, $expand." + }, + { + "name": "Get Billing Document", + "description": "Retrieve a single billing document (customer invoice) by BillingDocument key from SAP S/4HANA Cloud (API_BILLING_DOCUMENT_SRV, A_BillingDocument)." + }, + { + "name": "List Products", + "description": "List products (materials) from SAP S/4HANA Cloud (API_PRODUCT_SRV, A_Product) with optional OData $filter, $top, $skip, $orderby, $select, $expand." + }, + { + "name": "Get Product", + "description": "Retrieve a single product (material) by Product key from SAP S/4HANA Cloud (API_PRODUCT_SRV, A_Product)." + }, + { + "name": "Update Product", + "description": "Update fields on an A_Product entity in SAP S/4HANA Cloud (API_PRODUCT_SRV). PATCH only sends the fields you provide; existing values are preserved. Flat scalar header fields only — deep/multi-entity updates across navigation properties are not supported by API_PRODUCT_SRV PATCH/PUT (see SAP KBA 2833338); update child entities (plant, valuation, sales data, etc.) via their own endpoints. If-Match defaults to a wildcard (unconditional) — for safe concurrent updates pass the ETag from a prior GET." + }, + { + "name": "List Material Stock", + "description": "List material stock quantities from SAP S/4HANA Cloud (API_MATERIAL_STOCK_SRV, A_MatlStkInAcctMod). The entity uses an 11-field composite key (Material, Plant, StorageLocation, Batch, Supplier, Customer, WBSElementInternalID, SDDocument, SDDocumentItem, InventorySpecialStockType, InventoryStockType) — query with $filter on these fields instead of a direct key lookup." + }, + { + "name": "List Material Documents", + "description": "List material document headers (goods movements) from SAP S/4HANA Cloud (API_MATERIAL_DOCUMENT_SRV, A_MaterialDocumentHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand." + }, + { + "name": "List Purchase Requisitions", + "description": "List purchase requisitions from SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV, A_PurchaseRequisitionHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand. Note: API_PURCHASEREQ_PROCESS_SRV is deprecated since S/4HANA Cloud Public Edition 2402; the successor is API_PURCHASEREQUISITION_2 (OData v4). This tool still works against tenants where the legacy service is enabled." + }, + { + "name": "Get Purchase Requisition", + "description": "Retrieve a single purchase requisition by PurchaseRequisition key from SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV, A_PurchaseRequisitionHeader). Note: API_PURCHASEREQ_PROCESS_SRV is deprecated since S/4HANA Cloud Public Edition 2402; the successor is API_PURCHASEREQUISITION_2 (OData v4). This tool still works against tenants where the legacy service is enabled." + }, + { + "name": "Create Purchase Requisition", + "description": "Create a purchase requisition in SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV, A_PurchaseRequisitionHeader). PurchaseRequisition is auto-assigned by SAP from the document number range; provide line items via the to_PurchaseReqnItem deep-insert array. Note: API_PURCHASEREQ_PROCESS_SRV is deprecated since S/4HANA Cloud Public Edition 2402; the successor is API_PURCHASEREQUISITION_2 (OData v4). This tool still works against tenants where the legacy service is enabled." + }, + { + "name": "Update Purchase Requisition", + "description": "Update fields on an A_PurchaseRequisitionHeader entity in SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV; deprecated since S/4HANA 2402, successor is API_PURCHASEREQUISITION_2 OData v4). PATCH only sends the fields you provide; existing values are preserved. If-Match defaults to a wildcard - for safe concurrent updates pass the ETag from a prior GET to avoid lost updates." + }, + { + "name": "List Purchase Orders", + "description": "List purchase orders from SAP S/4HANA Cloud (API_PURCHASEORDER_PROCESS_SRV, A_PurchaseOrder) with optional OData $filter, $top, $skip, $orderby, $select, $expand." + }, + { + "name": "Get Purchase Order", + "description": "Retrieve a single purchase order by PurchaseOrder key from SAP S/4HANA Cloud (API_PURCHASEORDER_PROCESS_SRV, A_PurchaseOrder)." + }, + { + "name": "Create Purchase Order", + "description": "Create a purchase order in SAP S/4HANA Cloud (API_PURCHASEORDER_PROCESS_SRV, A_PurchaseOrder). PurchaseOrder is auto-assigned by SAP from the document number range; provide line items via the body parameter." + }, + { + "name": "Update Purchase Order", + "description": "Update fields on an A_PurchaseOrder entity in SAP S/4HANA Cloud (API_PURCHASEORDER_PROCESS_SRV). PATCH only sends the fields you provide; existing values are preserved. If-Match defaults to a wildcard (unconditional) — for safe concurrent updates pass the ETag from a prior GET to avoid lost updates." + }, + { + "name": "List Supplier Invoices", + "description": "List supplier invoices from SAP S/4HANA Cloud (API_SUPPLIERINVOICE_PROCESS_SRV, A_SupplierInvoice) with optional OData $filter, $top, $skip, $orderby, $select, $expand." + }, + { + "name": "Get Supplier Invoice", + "description": "Retrieve a single supplier invoice by composite key (SupplierInvoice + FiscalYear) from SAP S/4HANA Cloud (API_SUPPLIERINVOICE_PROCESS_SRV, A_SupplierInvoice)." + }, + { + "name": "OData Query (advanced)", + "description": "Make an arbitrary OData v2 call against any SAP S/4HANA Cloud whitelisted Communication Scenario. Use when no dedicated tool exists for the entity. The proxy handles auth, CSRF, and OData unwrapping." + } + ], + "operationCount": 37, + "triggers": [], + "triggerCount": 0, + "authType": "none", + "category": "tools", + "integrationTypes": ["other", "developer-tools"], + "tags": ["automation"] + }, { "type": "search", "slug": "search", diff --git a/apps/sim/app/api/tools/sap_s4hana/proxy/route.ts b/apps/sim/app/api/tools/sap_s4hana/proxy/route.ts new file mode 100644 index 00000000000..3ca70fb7f42 --- /dev/null +++ b/apps/sim/app/api/tools/sap_s4hana/proxy/route.ts @@ -0,0 +1,614 @@ +import { createHash } from 'node:crypto' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SapS4HanaProxyAPI') + +const HttpMethod = z.enum(['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'MERGE']) +const DeploymentType = z.enum(['cloud_public', 'cloud_private', 'on_premise']) +const AuthType = z.enum(['oauth_client_credentials', 'basic']) + +const ServiceName = z + .string() + .min(1, 'service is required') + .regex( + /^[A-Z][A-Z0-9_]*(;v=\d+)?$/, + 'service must be an uppercase OData service name optionally suffixed with ";v=NNNN" (e.g., API_BUSINESS_PARTNER, API_OUTBOUND_DELIVERY_SRV;v=0002)' + ) + +const ServicePath = z + .string() + .min(1, 'path is required') + .refine( + (p) => + !p.split(/[/\\]/).some((seg) => seg === '..' || seg === '.') && + !p.includes('?') && + !p.includes('#') && + !/%(?:2[eEfF]|5[cC]|3[fF]|23)/.test(p), + { + message: + 'path must not contain ".." or "." segments, "?", "#", or percent-encoded path/query/fragment characters', + } + ) + +const Subdomain = z + .string() + .regex( + /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i, + 'subdomain must contain only letters, digits, and hyphens (1-63 chars)' + ) + +const ProxyRequestSchema = z + .object({ + deploymentType: DeploymentType.default('cloud_public'), + authType: AuthType.default('oauth_client_credentials'), + subdomain: Subdomain.optional(), + region: z + .string() + .regex(/^[a-z]{2,4}\d{1,3}$/i, 'region must be an SAP BTP region code (e.g., eu10, us30)') + .optional(), + baseUrl: z.string().optional(), + tokenUrl: z.string().optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), + service: ServiceName, + path: ServicePath, + method: HttpMethod.default('GET'), + query: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(), + body: z.unknown().optional(), + ifMatch: z.string().optional(), + }) + .superRefine((req, ctx) => { + if (req.deploymentType === 'cloud_public') { + if (!req.subdomain) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['subdomain'], + message: 'subdomain is required for cloud_public deployment', + }) + } + if (!req.region) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['region'], + message: 'region is required for cloud_public deployment', + }) + } + if (req.authType !== 'oauth_client_credentials') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['authType'], + message: 'cloud_public deployment only supports oauth_client_credentials', + }) + } + if (!req.clientId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clientId'], + message: 'clientId is required', + }) + } + if (!req.clientSecret) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clientSecret'], + message: 'clientSecret is required', + }) + } + } else { + if (!req.baseUrl) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['baseUrl'], + message: 'baseUrl is required for cloud_private and on_premise deployments', + }) + } else { + const baseUrlCheck = checkExternalUrlSafety(req.baseUrl, 'baseUrl') + if (!baseUrlCheck.ok) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['baseUrl'], + message: baseUrlCheck.message, + }) + } + } + if (req.authType === 'oauth_client_credentials') { + if (!req.tokenUrl) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['tokenUrl'], + message: 'tokenUrl is required for OAuth on cloud_private/on_premise', + }) + } else { + const tokenUrlCheck = checkExternalUrlSafety(req.tokenUrl, 'tokenUrl') + if (!tokenUrlCheck.ok) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['tokenUrl'], + message: tokenUrlCheck.message, + }) + } + } + if (!req.clientId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clientId'], + message: 'clientId is required for OAuth', + }) + } + if (!req.clientSecret) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clientSecret'], + message: 'clientSecret is required for OAuth', + }) + } + } else { + if (!req.username) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['username'], + message: 'username is required for Basic auth', + }) + } + if (!req.password) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['password'], + message: 'password is required for Basic auth', + }) + } + } + } + }) + +type ProxyRequest = z.infer + +interface CachedToken { + accessToken: string + expiresAt: number +} + +const TOKEN_CACHE = new Map() +const TOKEN_CACHE_MAX_ENTRIES = 500 +const TOKEN_SAFETY_WINDOW_MS = 60_000 +const OUTBOUND_FETCH_TIMEOUT_MS = 30_000 + +const FORBIDDEN_HOSTS = new Set([ + 'localhost', + '0.0.0.0', + '127.0.0.1', + '169.254.169.254', + 'metadata.google.internal', + 'metadata', + '[::1]', + '[::]', + '[::ffff:127.0.0.1]', + '[fd00:ec2::254]', +]) + +function isPrivateIPv4(host: string): boolean { + const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (!match) return false + const octets = match.slice(1, 5).map(Number) as [number, number, number, number] + if (octets.some((o) => o < 0 || o > 255)) return false + const [a, b] = octets + if (a === 10) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 127) return true + if (a === 169 && b === 254) return true + if (a === 0) return true + return false +} + +function extractIPv4MappedHost(host: string): string | null { + const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host + const lower = stripped.toLowerCase() + for (const prefix of ['::ffff:', '::']) { + if (lower.startsWith(prefix)) { + const candidate = lower.slice(prefix.length) + if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(candidate)) return candidate + } + } + const hexMatch = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/) + if (hexMatch) { + const high = Number.parseInt(hexMatch[1] as string, 16) + const low = Number.parseInt(hexMatch[2] as string, 16) + if (high >= 0 && high <= 0xffff && low >= 0 && low <= 0xffff) { + const a = (high >> 8) & 0xff + const b = high & 0xff + const c = (low >> 8) & 0xff + const d = low & 0xff + return `${a}.${b}.${c}.${d}` + } + } + return null +} + +function isPrivateOrLoopbackIPv6(host: string): boolean { + const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host + const lower = stripped.toLowerCase() + if (lower === '::' || lower === '::1') return true + if (/^fc[0-9a-f]{2}:/.test(lower) || /^fd[0-9a-f]{2}:/.test(lower)) return true + if (lower.startsWith('fe80:')) return true + return false +} + +function checkExternalUrlSafety( + rawUrl: string, + label: string +): { ok: true; url: URL } | { ok: false; message: string } { + let parsed: URL + try { + parsed = new URL(rawUrl) + } catch { + return { ok: false, message: `${label} must be a valid URL` } + } + if (parsed.protocol !== 'https:') { + return { ok: false, message: `${label} must use https://` } + } + const host = parsed.hostname.toLowerCase() + if (FORBIDDEN_HOSTS.has(host) || FORBIDDEN_HOSTS.has(`[${host}]`)) { + return { ok: false, message: `${label} host is not allowed` } + } + if (isPrivateIPv4(host)) { + return { ok: false, message: `${label} host is not allowed (private/loopback range)` } + } + const mapped = extractIPv4MappedHost(host) + if (mapped && isPrivateIPv4(mapped)) { + return { ok: false, message: `${label} host is not allowed (IPv4-mapped private range)` } + } + if (isPrivateOrLoopbackIPv6(host)) { + return { ok: false, message: `${label} host is not allowed (IPv6 private/loopback)` } + } + return { ok: true, url: parsed } +} + +function assertSafeExternalUrl(rawUrl: string, label: string): URL { + const result = checkExternalUrlSafety(rawUrl, label) + if (!result.ok) throw new Error(result.message) + return result.url +} + +function resolveTokenUrl(req: ProxyRequest): string { + if (req.deploymentType === 'cloud_public') { + return `https://${req.subdomain}.authentication.${req.region}.hana.ondemand.com/oauth/token` + } + if (!req.tokenUrl) { + throw new Error('tokenUrl is required for OAuth on cloud_private/on_premise') + } + return req.tokenUrl +} + +function tokenCacheKey(req: ProxyRequest): string { + const secretHash = req.clientSecret + ? createHash('sha256').update(req.clientSecret).digest('hex').slice(0, 16) + : '' + return `${resolveTokenUrl(req)}::${req.clientId ?? ''}::${secretHash}` +} + +function rememberToken(key: string, token: CachedToken): void { + if (TOKEN_CACHE.has(key)) TOKEN_CACHE.delete(key) + TOKEN_CACHE.set(key, token) + while (TOKEN_CACHE.size > TOKEN_CACHE_MAX_ENTRIES) { + const oldestKey = TOKEN_CACHE.keys().next().value + if (oldestKey === undefined) break + TOKEN_CACHE.delete(oldestKey) + } +} + +async function fetchAccessToken(req: ProxyRequest, requestId: string): Promise { + const cacheKey = tokenCacheKey(req) + const cached = TOKEN_CACHE.get(cacheKey) + if (cached && cached.expiresAt - TOKEN_SAFETY_WINDOW_MS > Date.now()) { + return cached.accessToken + } + + const tokenUrl = assertSafeExternalUrl(resolveTokenUrl(req), 'tokenUrl').toString() + const basic = Buffer.from(`${req.clientId}:${req.clientSecret}`).toString('base64') + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + Authorization: `Basic ${basic}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: 'grant_type=client_credentials', + signal: AbortSignal.timeout(OUTBOUND_FETCH_TIMEOUT_MS), + }) + + if (!response.ok) { + const text = await response.text().catch(() => '') + logger.warn(`[${requestId}] Token fetch failed (${response.status}): ${text}`) + throw new Error(`SAP token request failed: HTTP ${response.status}`) + } + + const data = (await response.json()) as { + access_token?: string + expires_in?: number + } + + if (!data.access_token) { + throw new Error('SAP token response missing access_token') + } + + const expiresInMs = (data.expires_in ?? 3600) * 1000 + rememberToken(cacheKey, { + accessToken: data.access_token, + expiresAt: Date.now() + expiresInMs, + }) + return data.access_token +} + +interface CsrfBundle { + token: string + cookie: string +} + +function joinSetCookies(headers: Headers): string { + const cookies = + typeof (headers as { getSetCookie?: () => string[] }).getSetCookie === 'function' + ? (headers as { getSetCookie: () => string[] }).getSetCookie() + : (headers.get('set-cookie') ?? '').split(/,\s*(?=[^=,;\s]+=)/) + return cookies + .map((c) => c.split(';')[0]?.trim()) + .filter(Boolean) + .join('; ') +} + +function buildAuthHeader(req: ProxyRequest, accessToken: string | null): string { + if (req.authType === 'basic') { + const basic = Buffer.from(`${req.username}:${req.password}`).toString('base64') + return `Basic ${basic}` + } + return `Bearer ${accessToken}` +} + +async function fetchCsrf( + req: ProxyRequest, + accessToken: string | null, + requestId: string +): Promise { + const url = buildOdataUrl(req, '/$metadata') + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: buildAuthHeader(req, accessToken), + Accept: 'application/xml', + 'X-CSRF-Token': 'Fetch', + }, + signal: AbortSignal.timeout(OUTBOUND_FETCH_TIMEOUT_MS), + }) + + if (!response.ok) { + const text = await response.text().catch(() => '') + logger.warn(`[${requestId}] CSRF fetch failed (${response.status}): ${text}`) + return null + } + + const token = response.headers.get('x-csrf-token') + const cookie = joinSetCookies(response.headers) + if (!token) return null + return { token, cookie } +} + +function resolveHost(req: ProxyRequest): string { + if (req.deploymentType === 'cloud_public') { + const constructed = `https://${req.subdomain}-api.s4hana.ondemand.com` + return assertSafeExternalUrl(constructed, 'subdomain').toString().replace(/\/+$/, '') + } + if (!req.baseUrl) { + throw new Error('baseUrl is required for cloud_private and on_premise deployments') + } + const trimmed = req.baseUrl.replace(/\/+$/, '') + return assertSafeExternalUrl(trimmed, 'baseUrl').toString().replace(/\/+$/, '') +} + +function buildOdataUrl(req: ProxyRequest, pathOverride?: string): string { + const host = resolveHost(req) + const servicePath = `/sap/opu/odata/sap/${req.service}` + const subPath = pathOverride ?? req.path + const normalized = subPath.startsWith('/') ? subPath : `/${subPath}` + const base = `${host}${servicePath}${normalized}` + + if (pathOverride !== undefined) { + return base + } + if (!req.query || Object.keys(req.query).length === 0) { + return base + } + const encode = (s: string) => encodeURIComponent(s).replace(/%24/g, '$') + const parts: string[] = [] + for (const [key, value] of Object.entries(req.query)) { + if (value === undefined || value === null) continue + parts.push(`${encode(key)}=${encode(String(value))}`) + } + const queryString = parts.join('&') + if (!queryString) return base + return base.includes('?') ? `${base}&${queryString}` : `${base}?${queryString}` +} + +const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE', 'MERGE']) + +interface OdataInvocation { + status: number + body: unknown + raw: string + csrfHeader: string +} + +async function callOdata( + req: ProxyRequest, + accessToken: string | null, + csrf: CsrfBundle | null +): Promise { + const url = buildOdataUrl(req) + const headers: Record = { + Authorization: buildAuthHeader(req, accessToken), + Accept: 'application/json', + } + + const isWrite = WRITE_METHODS.has(req.method) + const hasBody = req.body !== undefined && req.body !== null + if (hasBody) headers['Content-Type'] = 'application/json' + if (req.ifMatch) headers['If-Match'] = req.ifMatch + + if (isWrite && csrf) { + headers['X-CSRF-Token'] = csrf.token + if (csrf.cookie) headers.Cookie = csrf.cookie + } + + const response = await fetch(url, { + method: req.method, + headers, + body: hasBody ? JSON.stringify(req.body) : undefined, + signal: AbortSignal.timeout(OUTBOUND_FETCH_TIMEOUT_MS), + }) + + const raw = await response.text() + let parsed: unknown = null + if (raw.length > 0) { + try { + parsed = JSON.parse(raw) + } catch { + parsed = raw + } + } + + const csrfHeader = response.headers.get('x-csrf-token')?.toLowerCase() ?? '' + return { status: response.status, body: parsed, raw, csrfHeader } +} + +function isCsrfRequired(invocation: OdataInvocation): boolean { + if (invocation.status !== 403) return false + if (invocation.csrfHeader === 'required') return true + if (typeof invocation.body !== 'object' || invocation.body === null) return false + const errorObj = (invocation.body as { error?: { message?: { value?: string } | string } }).error + const messageField = errorObj?.message + const message = typeof messageField === 'string' ? messageField : (messageField?.value ?? '') + return message.toLowerCase().includes('csrf') +} + +function extractOdataError(body: unknown, status: number): string { + if (body && typeof body === 'object') { + const err = ( + body as { + error?: { + message?: { value?: string } | string + code?: string + innererror?: { + errordetails?: Array<{ code?: string; message?: string; severity?: string }> + } + } + } + ).error + if (err) { + const messageField = err.message + const base = + typeof messageField === 'string' ? messageField : (messageField?.value ?? err.code ?? '') + const prefix = err.code ? `[${err.code}] ` : '' + const details = err.innererror?.errordetails + ?.filter((d) => d.message && (!d.severity || d.severity.toLowerCase() !== 'info')) + .map((d) => { + const tag = d.code ? `[${d.code}] ` : '' + return `${tag}${d.message}` + }) + .filter((m): m is string => Boolean(m)) + if (details && details.length > 0) { + const extras = details.filter((d) => !d.endsWith(base)) + return extras.length > 0 ? `${prefix}${base} (${extras.join('; ')})` : `${prefix}${base}` + } + if (base) return `${prefix}${base}` + } + } + if (typeof body === 'string' && body.length > 0) return body + return `SAP request failed with HTTP ${status}` +} + +function unwrapOdata(body: unknown): unknown { + if (!body || typeof body !== 'object') return body + const root = (body as { d?: unknown }).d + if (root === undefined) return body + if (root && typeof root === 'object' && 'results' in (root as Record)) { + const rootObj = root as { results: unknown; __count?: string; __next?: string } + if (rootObj.__count !== undefined || rootObj.__next !== undefined) { + return { + results: rootObj.results, + ...(rootObj.__count !== undefined && { __count: rootObj.__count }), + ...(rootObj.__next !== undefined && { __next: rootObj.__next }), + } + } + return rootObj.results + } + return root +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized SAP proxy request: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const json = await request.json() + const proxyReq = ProxyRequestSchema.parse(json) + const isWrite = WRITE_METHODS.has(proxyReq.method) + + const accessToken = + proxyReq.authType === 'oauth_client_credentials' + ? await fetchAccessToken(proxyReq, requestId) + : null + const csrf = isWrite ? await fetchCsrf(proxyReq, accessToken, requestId) : null + + let invocation = await callOdata(proxyReq, accessToken, csrf) + + if (isWrite && isCsrfRequired(invocation)) { + logger.info(`[${requestId}] CSRF token rejected, refetching and retrying`) + const refreshed = await fetchCsrf(proxyReq, accessToken, requestId) + if (refreshed) { + invocation = await callOdata(proxyReq, accessToken, refreshed) + } + } + + if (invocation.status >= 200 && invocation.status < 300) { + const data = invocation.status === 204 ? null : unwrapOdata(invocation.body) + return NextResponse.json({ success: true, output: { status: invocation.status, data } }) + } + + const message = extractOdataError(invocation.body, invocation.status) + logger.warn( + `[${requestId}] SAP API error (${invocation.status}) ${proxyReq.service}${proxyReq.path}: ${message}` + ) + return NextResponse.json( + { success: false, error: message, status: invocation.status }, + { status: invocation.status } + ) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Validation error:`, error.errors) + return NextResponse.json( + { success: false, error: error.errors[0]?.message || 'Validation failed' }, + { status: 400 } + ) + } + logger.error(`[${requestId}] Unexpected SAP proxy error:`, error) + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } +}) diff --git a/apps/sim/blocks/blocks/sap_s4hana.ts b/apps/sim/blocks/blocks/sap_s4hana.ts new file mode 100644 index 00000000000..ec0e878024f --- /dev/null +++ b/apps/sim/blocks/blocks/sap_s4hana.ts @@ -0,0 +1,1144 @@ +import { SapS4HanaIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import type { SapProxyResponse } from '@/tools/sap_s4hana/types' + +export const SapS4HanaBlock: BlockConfig = { + type: 'sap_s4hana', + name: 'SAP S/4HANA', + description: 'Read and write SAP S/4HANA Cloud business data via OData', + authMode: AuthMode.ApiKey, + longDescription: + 'Connect SAP S/4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario.', + docsLink: 'https://docs.sim.ai/tools/sap_s4hana', + category: 'tools', + integrationType: IntegrationType.Other, + tags: ['automation'], + bgColor: '#0A6ED1', + icon: SapS4HanaIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Business Partners', id: 'sap_s4hana_list_business_partners' }, + { label: 'Get Business Partner', id: 'sap_s4hana_get_business_partner' }, + { label: 'Create Business Partner', id: 'sap_s4hana_create_business_partner' }, + { label: 'Update Business Partner', id: 'sap_s4hana_update_business_partner' }, + { label: 'List Customers', id: 'sap_s4hana_list_customers' }, + { label: 'Get Customer', id: 'sap_s4hana_get_customer' }, + { label: 'Update Customer', id: 'sap_s4hana_update_customer' }, + { label: 'List Suppliers', id: 'sap_s4hana_list_suppliers' }, + { label: 'Get Supplier', id: 'sap_s4hana_get_supplier' }, + { label: 'Update Supplier', id: 'sap_s4hana_update_supplier' }, + { label: 'List Sales Orders', id: 'sap_s4hana_list_sales_orders' }, + { label: 'Get Sales Order', id: 'sap_s4hana_get_sales_order' }, + { label: 'Create Sales Order', id: 'sap_s4hana_create_sales_order' }, + { label: 'Update Sales Order', id: 'sap_s4hana_update_sales_order' }, + { label: 'Delete Sales Order', id: 'sap_s4hana_delete_sales_order' }, + { label: 'List Outbound Deliveries', id: 'sap_s4hana_list_outbound_deliveries' }, + { label: 'Get Outbound Delivery', id: 'sap_s4hana_get_outbound_delivery' }, + { label: 'List Inbound Deliveries', id: 'sap_s4hana_list_inbound_deliveries' }, + { label: 'Get Inbound Delivery', id: 'sap_s4hana_get_inbound_delivery' }, + { label: 'List Billing Documents', id: 'sap_s4hana_list_billing_documents' }, + { label: 'Get Billing Document', id: 'sap_s4hana_get_billing_document' }, + { label: 'List Products', id: 'sap_s4hana_list_products' }, + { label: 'Get Product', id: 'sap_s4hana_get_product' }, + { label: 'Update Product', id: 'sap_s4hana_update_product' }, + { label: 'List Material Stock', id: 'sap_s4hana_list_material_stock' }, + { label: 'List Material Documents', id: 'sap_s4hana_list_material_documents' }, + { label: 'List Purchase Requisitions', id: 'sap_s4hana_list_purchase_requisitions' }, + { label: 'Get Purchase Requisition', id: 'sap_s4hana_get_purchase_requisition' }, + { label: 'Create Purchase Requisition', id: 'sap_s4hana_create_purchase_requisition' }, + { label: 'Update Purchase Requisition', id: 'sap_s4hana_update_purchase_requisition' }, + { label: 'List Purchase Orders', id: 'sap_s4hana_list_purchase_orders' }, + { label: 'Get Purchase Order', id: 'sap_s4hana_get_purchase_order' }, + { label: 'Create Purchase Order', id: 'sap_s4hana_create_purchase_order' }, + { label: 'Update Purchase Order', id: 'sap_s4hana_update_purchase_order' }, + { label: 'List Supplier Invoices', id: 'sap_s4hana_list_supplier_invoices' }, + { label: 'Get Supplier Invoice', id: 'sap_s4hana_get_supplier_invoice' }, + { label: 'OData Query (advanced)', id: 'sap_s4hana_odata_query' }, + ], + value: () => 'sap_s4hana_list_business_partners', + required: true, + }, + + // List filters (shared across list operations) + { + id: 'filter', + title: '$filter', + type: 'long-input', + placeholder: "BusinessPartnerCategory eq '1'", + condition: { + field: 'operation', + value: [ + 'sap_s4hana_list_business_partners', + 'sap_s4hana_list_customers', + 'sap_s4hana_list_suppliers', + 'sap_s4hana_list_sales_orders', + 'sap_s4hana_list_outbound_deliveries', + 'sap_s4hana_list_inbound_deliveries', + 'sap_s4hana_list_billing_documents', + 'sap_s4hana_list_products', + 'sap_s4hana_list_material_stock', + 'sap_s4hana_list_material_documents', + 'sap_s4hana_list_purchase_requisitions', + 'sap_s4hana_list_purchase_orders', + 'sap_s4hana_list_supplier_invoices', + ], + }, + }, + { + id: 'top', + title: '$top', + type: 'short-input', + placeholder: '50', + condition: { + field: 'operation', + value: [ + 'sap_s4hana_list_business_partners', + 'sap_s4hana_list_customers', + 'sap_s4hana_list_suppliers', + 'sap_s4hana_list_sales_orders', + 'sap_s4hana_list_outbound_deliveries', + 'sap_s4hana_list_inbound_deliveries', + 'sap_s4hana_list_billing_documents', + 'sap_s4hana_list_products', + 'sap_s4hana_list_material_stock', + 'sap_s4hana_list_material_documents', + 'sap_s4hana_list_purchase_requisitions', + 'sap_s4hana_list_purchase_orders', + 'sap_s4hana_list_supplier_invoices', + ], + }, + }, + { + id: 'skip', + title: '$skip', + type: 'short-input', + placeholder: '0', + condition: { + field: 'operation', + value: [ + 'sap_s4hana_list_business_partners', + 'sap_s4hana_list_customers', + 'sap_s4hana_list_suppliers', + 'sap_s4hana_list_sales_orders', + 'sap_s4hana_list_outbound_deliveries', + 'sap_s4hana_list_inbound_deliveries', + 'sap_s4hana_list_billing_documents', + 'sap_s4hana_list_products', + 'sap_s4hana_list_material_stock', + 'sap_s4hana_list_material_documents', + 'sap_s4hana_list_purchase_requisitions', + 'sap_s4hana_list_purchase_orders', + 'sap_s4hana_list_supplier_invoices', + ], + }, + mode: 'advanced', + }, + { + id: 'orderBy', + title: '$orderby', + type: 'short-input', + placeholder: 'CreationDate desc', + condition: { + field: 'operation', + value: [ + 'sap_s4hana_list_business_partners', + 'sap_s4hana_list_customers', + 'sap_s4hana_list_suppliers', + 'sap_s4hana_list_sales_orders', + 'sap_s4hana_list_outbound_deliveries', + 'sap_s4hana_list_inbound_deliveries', + 'sap_s4hana_list_billing_documents', + 'sap_s4hana_list_products', + 'sap_s4hana_list_material_stock', + 'sap_s4hana_list_material_documents', + 'sap_s4hana_list_purchase_requisitions', + 'sap_s4hana_list_purchase_orders', + 'sap_s4hana_list_supplier_invoices', + ], + }, + mode: 'advanced', + }, + { + id: 'select', + title: '$select', + type: 'short-input', + placeholder: 'BusinessPartner,FirstName,LastName', + condition: { + field: 'operation', + value: [ + 'sap_s4hana_list_business_partners', + 'sap_s4hana_get_business_partner', + 'sap_s4hana_list_customers', + 'sap_s4hana_get_customer', + 'sap_s4hana_list_suppliers', + 'sap_s4hana_get_supplier', + 'sap_s4hana_list_sales_orders', + 'sap_s4hana_get_sales_order', + 'sap_s4hana_list_outbound_deliveries', + 'sap_s4hana_get_outbound_delivery', + 'sap_s4hana_list_inbound_deliveries', + 'sap_s4hana_get_inbound_delivery', + 'sap_s4hana_list_billing_documents', + 'sap_s4hana_get_billing_document', + 'sap_s4hana_list_products', + 'sap_s4hana_get_product', + 'sap_s4hana_list_material_stock', + 'sap_s4hana_list_material_documents', + 'sap_s4hana_list_purchase_requisitions', + 'sap_s4hana_get_purchase_requisition', + 'sap_s4hana_list_purchase_orders', + 'sap_s4hana_get_purchase_order', + 'sap_s4hana_list_supplier_invoices', + 'sap_s4hana_get_supplier_invoice', + ], + }, + mode: 'advanced', + }, + { + id: 'expand', + title: '$expand', + type: 'short-input', + placeholder: 'to_Item', + condition: { + field: 'operation', + value: [ + 'sap_s4hana_list_business_partners', + 'sap_s4hana_get_business_partner', + 'sap_s4hana_list_customers', + 'sap_s4hana_get_customer', + 'sap_s4hana_list_suppliers', + 'sap_s4hana_get_supplier', + 'sap_s4hana_list_sales_orders', + 'sap_s4hana_get_sales_order', + 'sap_s4hana_list_outbound_deliveries', + 'sap_s4hana_get_outbound_delivery', + 'sap_s4hana_list_inbound_deliveries', + 'sap_s4hana_get_inbound_delivery', + 'sap_s4hana_list_billing_documents', + 'sap_s4hana_get_billing_document', + 'sap_s4hana_list_products', + 'sap_s4hana_get_product', + 'sap_s4hana_list_material_stock', + 'sap_s4hana_list_material_documents', + 'sap_s4hana_list_purchase_requisitions', + 'sap_s4hana_get_purchase_requisition', + 'sap_s4hana_list_purchase_orders', + 'sap_s4hana_get_purchase_order', + 'sap_s4hana_list_supplier_invoices', + 'sap_s4hana_get_supplier_invoice', + ], + }, + mode: 'advanced', + }, + + // Business Partner: get/create + { + id: 'businessPartner', + title: 'BusinessPartner', + type: 'short-input', + placeholder: '1000123', + condition: { + field: 'operation', + value: ['sap_s4hana_get_business_partner', 'sap_s4hana_update_business_partner'], + }, + required: true, + }, + { + id: 'businessPartnerCategory', + title: 'BusinessPartnerCategory', + type: 'dropdown', + options: [ + { label: '1 — Person', id: '1' }, + { label: '2 — Organization', id: '2' }, + { label: '3 — Group', id: '3' }, + ], + value: () => '2', + condition: { field: 'operation', value: 'sap_s4hana_create_business_partner' }, + required: true, + }, + { + id: 'businessPartnerGrouping', + title: 'BusinessPartnerGrouping', + type: 'short-input', + placeholder: 'Tenant-configured grouping (see customizing)', + condition: { field: 'operation', value: 'sap_s4hana_create_business_partner' }, + required: true, + }, + { + id: 'firstName', + title: 'FirstName', + type: 'short-input', + placeholder: 'Required for Person', + condition: { + field: 'operation', + value: 'sap_s4hana_create_business_partner', + and: { field: 'businessPartnerCategory', value: '1' }, + }, + required: { + field: 'operation', + value: 'sap_s4hana_create_business_partner', + and: { field: 'businessPartnerCategory', value: '1' }, + }, + }, + { + id: 'lastName', + title: 'LastName', + type: 'short-input', + placeholder: 'Required for Person', + condition: { + field: 'operation', + value: 'sap_s4hana_create_business_partner', + and: { field: 'businessPartnerCategory', value: '1' }, + }, + required: { + field: 'operation', + value: 'sap_s4hana_create_business_partner', + and: { field: 'businessPartnerCategory', value: '1' }, + }, + }, + { + id: 'organizationBPName1', + title: 'OrganizationBPName1', + type: 'short-input', + placeholder: 'Required for Organization', + condition: { + field: 'operation', + value: 'sap_s4hana_create_business_partner', + and: { field: 'businessPartnerCategory', value: '2' }, + }, + required: { + field: 'operation', + value: 'sap_s4hana_create_business_partner', + and: { field: 'businessPartnerCategory', value: '2' }, + }, + }, + { + id: 'businessPartnerBody', + title: 'Additional Fields (JSON)', + type: 'code', + placeholder: '{"CorrespondenceLanguage":"EN"}', + condition: { field: 'operation', value: 'sap_s4hana_create_business_partner' }, + mode: 'advanced', + }, + + // Customer: get + { + id: 'customer', + title: 'Customer', + type: 'short-input', + placeholder: '17100001', + condition: { + field: 'operation', + value: ['sap_s4hana_get_customer', 'sap_s4hana_update_customer'], + }, + required: true, + }, + + // Sales Order: get/create + { + id: 'salesOrder', + title: 'SalesOrder', + type: 'short-input', + placeholder: '1', + condition: { + field: 'operation', + value: [ + 'sap_s4hana_get_sales_order', + 'sap_s4hana_update_sales_order', + 'sap_s4hana_delete_sales_order', + ], + }, + required: true, + }, + { + id: 'salesOrderType', + title: 'SalesOrderType', + type: 'short-input', + placeholder: 'OR', + condition: { field: 'operation', value: 'sap_s4hana_create_sales_order' }, + required: true, + }, + { + id: 'salesOrganization', + title: 'SalesOrganization', + type: 'short-input', + placeholder: '1010', + condition: { field: 'operation', value: 'sap_s4hana_create_sales_order' }, + required: true, + }, + { + id: 'distributionChannel', + title: 'DistributionChannel', + type: 'short-input', + placeholder: '10', + condition: { field: 'operation', value: 'sap_s4hana_create_sales_order' }, + required: true, + }, + { + id: 'organizationDivision', + title: 'OrganizationDivision', + type: 'short-input', + placeholder: '00', + condition: { field: 'operation', value: 'sap_s4hana_create_sales_order' }, + required: true, + }, + { + id: 'soldToParty', + title: 'SoldToParty', + type: 'short-input', + placeholder: '17100001', + condition: { field: 'operation', value: 'sap_s4hana_create_sales_order' }, + required: true, + }, + { + id: 'salesOrderItems', + title: 'Items (to_Item, JSON array)', + type: 'code', + placeholder: '[{"Material":"TG11","RequestedQuantity":"1"}]', + condition: { field: 'operation', value: 'sap_s4hana_create_sales_order' }, + required: true, + }, + { + id: 'salesOrderBody', + title: 'Additional Fields (JSON)', + type: 'code', + placeholder: '{"PurchaseOrderByCustomer":"PO-12345"}', + condition: { field: 'operation', value: 'sap_s4hana_create_sales_order' }, + mode: 'advanced', + }, + + // Delivery Document: shared by outbound and inbound + { + id: 'deliveryDocument', + title: 'DeliveryDocument', + type: 'short-input', + placeholder: '80000000', + condition: { + field: 'operation', + value: ['sap_s4hana_get_outbound_delivery', 'sap_s4hana_get_inbound_delivery'], + }, + required: true, + }, + + // Billing Document: get + { + id: 'billingDocument', + title: 'BillingDocument', + type: 'short-input', + placeholder: '90000000', + condition: { field: 'operation', value: 'sap_s4hana_get_billing_document' }, + required: true, + }, + + // Product: get + { + id: 'product', + title: 'Product', + type: 'short-input', + placeholder: 'TG11', + condition: { + field: 'operation', + value: ['sap_s4hana_get_product', 'sap_s4hana_update_product'], + }, + required: true, + }, + + // Purchase Requisition: get/update + { + id: 'purchaseRequisition', + title: 'PurchaseRequisition', + type: 'short-input', + placeholder: '10000000', + condition: { + field: 'operation', + value: ['sap_s4hana_get_purchase_requisition', 'sap_s4hana_update_purchase_requisition'], + }, + required: true, + }, + // Purchase Requisition: create + { + id: 'purchaseRequisitionType', + title: 'PurchaseRequisitionType', + type: 'short-input', + placeholder: 'NB', + condition: { field: 'operation', value: 'sap_s4hana_create_purchase_requisition' }, + required: true, + }, + { + id: 'purchaseRequisitionItems', + title: 'Items (to_PurchaseReqnItem, JSON array)', + type: 'code', + placeholder: + '[{"PurchaseRequisitionItem":"10","Material":"TG11","RequestedQuantity":"5","Plant":"1010","BaseUnit":"PC"}]', + condition: { field: 'operation', value: 'sap_s4hana_create_purchase_requisition' }, + required: true, + }, + { + id: 'purchaseRequisitionBody', + title: 'Additional Fields (JSON)', + type: 'code', + placeholder: '{"PurchaseRequisitionDescription":"Office supplies"}', + condition: { field: 'operation', value: 'sap_s4hana_create_purchase_requisition' }, + mode: 'advanced', + }, + + // Purchase Order: get/create + { + id: 'purchaseOrder', + title: 'PurchaseOrder', + type: 'short-input', + placeholder: '4500000001', + condition: { + field: 'operation', + value: ['sap_s4hana_get_purchase_order', 'sap_s4hana_update_purchase_order'], + }, + required: true, + }, + { + id: 'purchaseOrderType', + title: 'PurchaseOrderType', + type: 'short-input', + placeholder: 'NB', + condition: { field: 'operation', value: 'sap_s4hana_create_purchase_order' }, + required: true, + }, + { + id: 'companyCode', + title: 'CompanyCode', + type: 'short-input', + placeholder: '1010', + condition: { field: 'operation', value: 'sap_s4hana_create_purchase_order' }, + required: true, + }, + { + id: 'purchasingOrganization', + title: 'PurchasingOrganization', + type: 'short-input', + placeholder: '1010', + condition: { field: 'operation', value: 'sap_s4hana_create_purchase_order' }, + required: true, + }, + { + id: 'purchasingGroup', + title: 'PurchasingGroup', + type: 'short-input', + placeholder: '001', + condition: { field: 'operation', value: 'sap_s4hana_create_purchase_order' }, + required: true, + }, + { + id: 'supplier', + title: 'Supplier', + type: 'short-input', + placeholder: '17300001', + condition: { + field: 'operation', + value: [ + 'sap_s4hana_create_purchase_order', + 'sap_s4hana_get_supplier', + 'sap_s4hana_update_supplier', + ], + }, + required: true, + }, + { + id: 'purchaseOrderBody', + title: 'Items & Additional Fields (JSON)', + type: 'code', + placeholder: + '{"to_PurchaseOrderItem":[{"PurchaseOrderItem":"10","Material":"TG11","OrderQuantity":"5","Plant":"1010","PurchaseOrderQuantityUnit":"PC","NetPriceAmount":"100.00","DocumentCurrency":"USD"}]}', + condition: { field: 'operation', value: 'sap_s4hana_create_purchase_order' }, + required: true, + }, + + // Supplier Invoice: get + { + id: 'supplierInvoice', + title: 'SupplierInvoice', + type: 'short-input', + placeholder: '5105600000', + condition: { field: 'operation', value: 'sap_s4hana_get_supplier_invoice' }, + required: true, + }, + { + id: 'fiscalYear', + title: 'FiscalYear', + type: 'short-input', + placeholder: '2024', + condition: { field: 'operation', value: 'sap_s4hana_get_supplier_invoice' }, + required: true, + }, + + // Shared body for all PATCH update operations + { + id: 'updateBody', + title: 'Fields to Update (JSON)', + type: 'code', + placeholder: '{"FirstName":"Jane","SearchTerm1":"VIP"}', + condition: { + field: 'operation', + value: [ + 'sap_s4hana_update_business_partner', + 'sap_s4hana_update_customer', + 'sap_s4hana_update_supplier', + 'sap_s4hana_update_product', + 'sap_s4hana_update_sales_order', + 'sap_s4hana_update_purchase_order', + 'sap_s4hana_update_purchase_requisition', + ], + }, + required: true, + }, + // Shared If-Match for all update + delete operations + { + id: 'updateIfMatch', + title: 'If-Match (ETag)', + type: 'short-input', + placeholder: '* (default — bypass concurrency check)', + condition: { + field: 'operation', + value: [ + 'sap_s4hana_update_business_partner', + 'sap_s4hana_update_customer', + 'sap_s4hana_update_supplier', + 'sap_s4hana_update_product', + 'sap_s4hana_update_sales_order', + 'sap_s4hana_delete_sales_order', + 'sap_s4hana_update_purchase_order', + 'sap_s4hana_update_purchase_requisition', + ], + }, + mode: 'advanced', + }, + + // OData Query passthrough + { + id: 'odataService', + title: 'OData Service', + type: 'short-input', + placeholder: 'API_BUSINESS_PARTNER', + condition: { field: 'operation', value: 'sap_s4hana_odata_query' }, + required: true, + }, + { + id: 'odataPath', + title: 'Entity Path', + type: 'short-input', + placeholder: "/A_BusinessPartner('1000123')", + condition: { field: 'operation', value: 'sap_s4hana_odata_query' }, + required: true, + }, + { + id: 'odataMethod', + title: 'HTTP Method', + type: 'dropdown', + options: [ + { label: 'GET', id: 'GET' }, + { label: 'POST', id: 'POST' }, + { label: 'PATCH', id: 'PATCH' }, + { label: 'PUT', id: 'PUT' }, + { label: 'DELETE', id: 'DELETE' }, + { label: 'MERGE', id: 'MERGE' }, + ], + value: () => 'GET', + condition: { field: 'operation', value: 'sap_s4hana_odata_query' }, + }, + { + id: 'odataQuery', + title: 'Query Parameters (JSON or query string)', + type: 'code', + placeholder: '{"$filter":"BusinessPartnerCategory eq \'1\'","$top":10}', + condition: { field: 'operation', value: 'sap_s4hana_odata_query' }, + mode: 'advanced', + }, + { + id: 'odataBody', + title: 'Request Body (JSON)', + type: 'code', + placeholder: '{"FirstName":"Jane"}', + condition: { field: 'operation', value: 'sap_s4hana_odata_query' }, + mode: 'advanced', + }, + { + id: 'odataIfMatch', + title: 'If-Match (ETag)', + type: 'short-input', + placeholder: 'W/"datetimeoffset\'2024-01-01T00:00:00Z\'"', + condition: { field: 'operation', value: 'sap_s4hana_odata_query' }, + mode: 'advanced', + }, + + // Connection (always shown) + { + id: 'deploymentType', + title: 'Deployment', + type: 'dropdown', + options: [ + { label: 'S/4HANA Cloud Public Edition', id: 'cloud_public' }, + { label: 'S/4HANA Cloud Private Edition (RISE)', id: 'cloud_private' }, + { label: 'S/4HANA On-Premise', id: 'on_premise' }, + ], + value: () => 'cloud_public', + required: true, + }, + { + id: 'authType', + title: 'Authentication', + type: 'dropdown', + options: [ + { label: 'OAuth 2.0 Client Credentials', id: 'oauth_client_credentials' }, + { label: 'Basic (Communication User)', id: 'basic' }, + ], + value: () => 'oauth_client_credentials', + condition: { field: 'deploymentType', value: ['cloud_private', 'on_premise'] }, + required: { field: 'deploymentType', value: ['cloud_private', 'on_premise'] }, + dependsOn: ['deploymentType'], + }, + + // Cloud Public: subdomain + region (SAP BTP UAA pattern) + { + id: 'subdomain', + title: 'BTP Subdomain', + type: 'short-input', + placeholder: 'my-tenant', + condition: { field: 'deploymentType', value: 'cloud_public' }, + required: { field: 'deploymentType', value: 'cloud_public' }, + }, + { + id: 'region', + title: 'BTP Region', + type: 'dropdown', + options: [ + { label: 'eu10 — Europe / Frankfurt (AWS)', id: 'eu10' }, + { label: 'eu11 — Europe / Frankfurt (AWS, EU Access)', id: 'eu11' }, + { label: 'eu20 — Europe / Netherlands (Azure)', id: 'eu20' }, + { label: 'eu22 — Europe / Zurich (Azure)', id: 'eu22' }, + { label: 'eu30 — Europe / Frankfurt (GCP)', id: 'eu30' }, + { label: 'uk20 — UK South (Azure)', id: 'uk20' }, + { label: 'ch20 — Switzerland North (Azure)', id: 'ch20' }, + { label: 'us10 — US East / Virginia (AWS)', id: 'us10' }, + { label: 'us11 — US West / Oregon (AWS)', id: 'us11' }, + { label: 'us20 — US East 2 / Virginia (Azure)', id: 'us20' }, + { label: 'us21 — US Central / Iowa (Azure)', id: 'us21' }, + { label: 'us30 — US Central / Iowa (GCP)', id: 'us30' }, + { label: 'ca10 — Canada / Montreal (AWS)', id: 'ca10' }, + { label: 'ca20 — Canada Central / Toronto (Azure)', id: 'ca20' }, + { label: 'br10 — Brazil / São Paulo (AWS)', id: 'br10' }, + { label: 'br20 — Brazil South (Azure)', id: 'br20' }, + { label: 'br30 — Brazil / São Paulo (GCP)', id: 'br30' }, + { label: 'jp10 — Japan / Tokyo (AWS)', id: 'jp10' }, + { label: 'jp20 — Japan East / Tokyo (Azure)', id: 'jp20' }, + { label: 'jp30 — Japan / Tokyo (GCP)', id: 'jp30' }, + { label: 'jp31 — Japan / Osaka (GCP)', id: 'jp31' }, + { label: 'ap10 — Australia / Sydney (AWS)', id: 'ap10' }, + { label: 'ap11 — Singapore (AWS)', id: 'ap11' }, + { label: 'ap12 — South Korea / Seoul (AWS)', id: 'ap12' }, + { label: 'ap20 — Australia East / Sydney (Azure)', id: 'ap20' }, + { label: 'ap21 — East Asia / Hong Kong (Azure)', id: 'ap21' }, + { label: 'ap30 — Asia Pacific / Sydney (GCP)', id: 'ap30' }, + { label: 'in30 — India (GCP)', id: 'in30' }, + { label: 'il30 — Israel (GCP)', id: 'il30' }, + { label: 'sa30 — Saudi Arabia / Dammam (GCP)', id: 'sa30' }, + { label: 'sa31 — Saudi Arabia / Riyadh (GCP)', id: 'sa31' }, + ], + value: () => 'eu10', + condition: { field: 'deploymentType', value: 'cloud_public' }, + required: { field: 'deploymentType', value: 'cloud_public' }, + }, + + // Private / On-Prem: explicit host (and token URL for OAuth) + { + id: 'baseUrl', + title: 'Base URL', + type: 'short-input', + placeholder: 'https://s4h.example.com:44300', + condition: { field: 'deploymentType', value: ['cloud_private', 'on_premise'] }, + required: { field: 'deploymentType', value: ['cloud_private', 'on_premise'] }, + }, + { + id: 'tokenUrl', + title: 'OAuth Token URL', + type: 'short-input', + placeholder: 'https://auth.example.com/oauth/token', + condition: { + field: 'deploymentType', + value: ['cloud_private', 'on_premise'], + and: { field: 'authType', value: 'oauth_client_credentials' }, + }, + required: { + field: 'deploymentType', + value: ['cloud_private', 'on_premise'], + and: { field: 'authType', value: 'oauth_client_credentials' }, + }, + }, + + // OAuth credentials (shown whenever authType is oauth_client_credentials — cloud_public defaults to this) + { + id: 'clientId', + title: 'OAuth Client ID', + type: 'short-input', + placeholder: 'sb-...!b1234', + password: true, + condition: { field: 'authType', value: 'basic', not: true }, + required: { field: 'authType', value: 'basic', not: true }, + }, + { + id: 'clientSecret', + title: 'OAuth Client Secret', + type: 'short-input', + placeholder: 'Client secret from Communication Arrangement', + password: true, + condition: { field: 'authType', value: 'basic', not: true }, + required: { field: 'authType', value: 'basic', not: true }, + }, + + // Basic credentials (only surfaced on Private/On-Prem + Basic auth) + { + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'Communication user (e.g., CC_ORDERS_USER)', + condition: { field: 'authType', value: 'basic' }, + required: { field: 'authType', value: 'basic' }, + }, + { + id: 'password', + title: 'Password', + type: 'short-input', + placeholder: 'Password for the communication user', + password: true, + condition: { field: 'authType', value: 'basic' }, + required: { field: 'authType', value: 'basic' }, + }, + ], + tools: { + access: [ + 'sap_s4hana_list_business_partners', + 'sap_s4hana_get_business_partner', + 'sap_s4hana_create_business_partner', + 'sap_s4hana_update_business_partner', + 'sap_s4hana_list_customers', + 'sap_s4hana_get_customer', + 'sap_s4hana_update_customer', + 'sap_s4hana_list_suppliers', + 'sap_s4hana_get_supplier', + 'sap_s4hana_update_supplier', + 'sap_s4hana_list_sales_orders', + 'sap_s4hana_get_sales_order', + 'sap_s4hana_create_sales_order', + 'sap_s4hana_update_sales_order', + 'sap_s4hana_delete_sales_order', + 'sap_s4hana_list_outbound_deliveries', + 'sap_s4hana_get_outbound_delivery', + 'sap_s4hana_list_inbound_deliveries', + 'sap_s4hana_get_inbound_delivery', + 'sap_s4hana_list_billing_documents', + 'sap_s4hana_get_billing_document', + 'sap_s4hana_list_products', + 'sap_s4hana_get_product', + 'sap_s4hana_update_product', + 'sap_s4hana_list_material_stock', + 'sap_s4hana_list_material_documents', + 'sap_s4hana_list_purchase_requisitions', + 'sap_s4hana_get_purchase_requisition', + 'sap_s4hana_create_purchase_requisition', + 'sap_s4hana_update_purchase_requisition', + 'sap_s4hana_list_purchase_orders', + 'sap_s4hana_get_purchase_order', + 'sap_s4hana_create_purchase_order', + 'sap_s4hana_update_purchase_order', + 'sap_s4hana_list_supplier_invoices', + 'sap_s4hana_get_supplier_invoice', + 'sap_s4hana_odata_query', + ], + config: { + tool: (params) => params.operation, + params: (params) => { + const auth = { + deploymentType: params.deploymentType || 'cloud_public', + authType: params.authType || 'oauth_client_credentials', + subdomain: params.subdomain || undefined, + region: params.region || undefined, + baseUrl: params.baseUrl || undefined, + tokenUrl: params.tokenUrl || undefined, + clientId: params.clientId || undefined, + clientSecret: params.clientSecret || undefined, + username: params.username || undefined, + password: params.password || undefined, + } + const listFields = { + filter: params.filter || undefined, + top: params.top ? Number(params.top) : undefined, + skip: params.skip ? Number(params.skip) : undefined, + orderBy: params.orderBy || undefined, + select: params.select || undefined, + expand: params.expand || undefined, + } + const entityFields = { + select: params.select || undefined, + expand: params.expand || undefined, + } + + switch (params.operation) { + case 'sap_s4hana_list_business_partners': + return { ...auth, ...listFields } + case 'sap_s4hana_get_business_partner': + return { ...auth, ...entityFields, businessPartner: params.businessPartner } + case 'sap_s4hana_create_business_partner': + return { + ...auth, + businessPartnerCategory: params.businessPartnerCategory, + businessPartnerGrouping: params.businessPartnerGrouping, + firstName: params.firstName || undefined, + lastName: params.lastName || undefined, + organizationBPName1: params.organizationBPName1 || undefined, + body: params.businessPartnerBody || undefined, + } + case 'sap_s4hana_update_business_partner': + return { + ...auth, + businessPartner: params.businessPartner, + body: params.updateBody, + ifMatch: params.updateIfMatch || undefined, + } + case 'sap_s4hana_list_customers': + return { ...auth, ...listFields } + case 'sap_s4hana_get_customer': + return { ...auth, ...entityFields, customer: params.customer } + case 'sap_s4hana_update_customer': + return { + ...auth, + customer: params.customer, + body: params.updateBody, + ifMatch: params.updateIfMatch || undefined, + } + case 'sap_s4hana_list_suppliers': + return { ...auth, ...listFields } + case 'sap_s4hana_get_supplier': + return { ...auth, ...entityFields, supplier: params.supplier } + case 'sap_s4hana_update_supplier': + return { + ...auth, + supplier: params.supplier, + body: params.updateBody, + ifMatch: params.updateIfMatch || undefined, + } + case 'sap_s4hana_list_sales_orders': + return { ...auth, ...listFields } + case 'sap_s4hana_get_sales_order': + return { ...auth, ...entityFields, salesOrder: params.salesOrder } + case 'sap_s4hana_create_sales_order': + return { + ...auth, + salesOrderType: params.salesOrderType, + salesOrganization: params.salesOrganization, + distributionChannel: params.distributionChannel, + organizationDivision: params.organizationDivision, + soldToParty: params.soldToParty, + items: params.salesOrderItems, + body: params.salesOrderBody || undefined, + } + case 'sap_s4hana_update_sales_order': + return { + ...auth, + salesOrder: params.salesOrder, + body: params.updateBody, + ifMatch: params.updateIfMatch || undefined, + } + case 'sap_s4hana_delete_sales_order': + return { + ...auth, + salesOrder: params.salesOrder, + ifMatch: params.updateIfMatch || undefined, + } + case 'sap_s4hana_list_outbound_deliveries': + return { ...auth, ...listFields } + case 'sap_s4hana_get_outbound_delivery': + return { + ...auth, + ...entityFields, + deliveryDocument: params.deliveryDocument, + } + case 'sap_s4hana_list_inbound_deliveries': + return { ...auth, ...listFields } + case 'sap_s4hana_get_inbound_delivery': + return { + ...auth, + ...entityFields, + deliveryDocument: params.deliveryDocument, + } + case 'sap_s4hana_list_billing_documents': + return { ...auth, ...listFields } + case 'sap_s4hana_get_billing_document': + return { ...auth, ...entityFields, billingDocument: params.billingDocument } + case 'sap_s4hana_list_products': + return { ...auth, ...listFields } + case 'sap_s4hana_get_product': + return { ...auth, ...entityFields, product: params.product } + case 'sap_s4hana_update_product': + return { + ...auth, + product: params.product, + body: params.updateBody, + ifMatch: params.updateIfMatch || undefined, + } + case 'sap_s4hana_list_material_stock': + return { ...auth, ...listFields } + case 'sap_s4hana_list_material_documents': + return { ...auth, ...listFields } + case 'sap_s4hana_list_purchase_requisitions': + return { ...auth, ...listFields } + case 'sap_s4hana_get_purchase_requisition': + return { + ...auth, + ...entityFields, + purchaseRequisition: params.purchaseRequisition, + } + case 'sap_s4hana_create_purchase_requisition': + return { + ...auth, + purchaseRequisitionType: params.purchaseRequisitionType, + items: params.purchaseRequisitionItems, + body: params.purchaseRequisitionBody || undefined, + } + case 'sap_s4hana_update_purchase_requisition': + return { + ...auth, + purchaseRequisition: params.purchaseRequisition, + body: params.updateBody, + ifMatch: params.updateIfMatch || undefined, + } + case 'sap_s4hana_list_purchase_orders': + return { ...auth, ...listFields } + case 'sap_s4hana_get_purchase_order': + return { ...auth, ...entityFields, purchaseOrder: params.purchaseOrder } + case 'sap_s4hana_create_purchase_order': + return { + ...auth, + purchaseOrderType: params.purchaseOrderType, + companyCode: params.companyCode, + purchasingOrganization: params.purchasingOrganization, + purchasingGroup: params.purchasingGroup, + supplier: params.supplier, + body: params.purchaseOrderBody || undefined, + } + case 'sap_s4hana_update_purchase_order': + return { + ...auth, + purchaseOrder: params.purchaseOrder, + body: params.updateBody, + ifMatch: params.updateIfMatch || undefined, + } + case 'sap_s4hana_list_supplier_invoices': + return { ...auth, ...listFields } + case 'sap_s4hana_get_supplier_invoice': + return { + ...auth, + ...entityFields, + supplierInvoice: params.supplierInvoice, + fiscalYear: params.fiscalYear, + } + case 'sap_s4hana_odata_query': + return { + ...auth, + service: params.odataService, + path: params.odataPath, + method: params.odataMethod || 'GET', + query: params.odataQuery || undefined, + body: params.odataBody || undefined, + ifMatch: params.odataIfMatch || undefined, + } + default: + return auth + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + deploymentType: { + type: 'string', + description: 'cloud_public | cloud_private | on_premise', + }, + authType: { + type: 'string', + description: 'oauth_client_credentials | basic', + }, + subdomain: { type: 'string', description: 'BTP subdomain (Cloud Public)' }, + region: { type: 'string', description: 'BTP region (Cloud Public, e.g., eu10, us10)' }, + baseUrl: { type: 'string', description: 'Base URL (Cloud Private / On-Premise)' }, + tokenUrl: { + type: 'string', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + clientId: { type: 'string', description: 'OAuth client ID' }, + clientSecret: { type: 'string', description: 'OAuth client secret' }, + username: { type: 'string', description: 'Username (Basic auth)' }, + password: { type: 'string', description: 'Password (Basic auth)' }, + filter: { type: 'string', description: 'OData $filter expression' }, + top: { type: 'number', description: 'OData $top' }, + skip: { type: 'number', description: 'OData $skip' }, + orderBy: { type: 'string', description: 'OData $orderby expression' }, + select: { type: 'string', description: 'OData $select fields' }, + expand: { type: 'string', description: 'OData $expand navigation properties' }, + businessPartner: { type: 'string', description: 'BusinessPartner key' }, + businessPartnerCategory: { type: 'string', description: 'BusinessPartnerCategory (1, 2, 3)' }, + businessPartnerGrouping: { type: 'string', description: 'BusinessPartnerGrouping' }, + firstName: { type: 'string', description: 'FirstName for Person' }, + lastName: { type: 'string', description: 'LastName for Person' }, + organizationBPName1: { type: 'string', description: 'OrganizationBPName1 for Organization' }, + businessPartnerBody: { type: 'json', description: 'Additional A_BusinessPartner fields' }, + customer: { type: 'string', description: 'Customer key' }, + salesOrder: { type: 'string', description: 'SalesOrder key' }, + salesOrderType: { type: 'string', description: 'SalesOrderType' }, + salesOrganization: { type: 'string', description: 'SalesOrganization' }, + distributionChannel: { type: 'string', description: 'DistributionChannel' }, + organizationDivision: { type: 'string', description: 'OrganizationDivision' }, + soldToParty: { type: 'string', description: 'SoldToParty business partner key' }, + salesOrderItems: { type: 'json', description: 'Sales order items for to_Item deep insert' }, + salesOrderBody: { type: 'json', description: 'Additional A_SalesOrder fields' }, + deliveryDocument: { type: 'string', description: 'DeliveryDocument key' }, + billingDocument: { type: 'string', description: 'BillingDocument key' }, + product: { type: 'string', description: 'Product key' }, + purchaseRequisition: { type: 'string', description: 'PurchaseRequisition key' }, + purchaseRequisitionType: { type: 'string', description: 'PurchaseRequisitionType' }, + purchaseRequisitionItems: { + type: 'json', + description: 'Purchase requisition items for to_PurchaseReqnItem deep insert', + }, + purchaseRequisitionBody: { + type: 'json', + description: 'Additional A_PurchaseRequisitionHeader fields', + }, + purchaseOrder: { type: 'string', description: 'PurchaseOrder key' }, + purchaseOrderType: { type: 'string', description: 'PurchaseOrderType' }, + companyCode: { type: 'string', description: 'CompanyCode' }, + purchasingOrganization: { type: 'string', description: 'PurchasingOrganization' }, + purchasingGroup: { type: 'string', description: 'PurchasingGroup' }, + supplier: { type: 'string', description: 'Supplier business partner key' }, + purchaseOrderBody: { type: 'json', description: 'Items and additional A_PurchaseOrder fields' }, + supplierInvoice: { type: 'string', description: 'SupplierInvoice key' }, + fiscalYear: { type: 'string', description: 'FiscalYear (4-digit year)' }, + odataService: { type: 'string', description: 'OData service name' }, + odataPath: { type: 'string', description: 'OData entity path' }, + odataMethod: { type: 'string', description: 'HTTP method for OData call' }, + odataQuery: { type: 'json', description: 'OData query parameters' }, + odataBody: { type: 'json', description: 'OData request body' }, + odataIfMatch: { type: 'string', description: 'If-Match ETag header' }, + updateBody: { type: 'json', description: 'JSON object with fields to update' }, + updateIfMatch: { + type: 'string', + description: 'If-Match ETag for update/delete (defaults to "*")', + }, + }, + outputs: { + success: { type: 'boolean', description: 'Whether the operation succeeded' }, + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Parsed OData payload (entity, collection, or null)' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 35901226841..e7ca943af3c 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -169,6 +169,7 @@ import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router' import { RssBlock } from '@/blocks/blocks/rss' import { S3Block } from '@/blocks/blocks/s3' import { SalesforceBlock } from '@/blocks/blocks/salesforce' +import { SapS4HanaBlock } from '@/blocks/blocks/sap_s4hana' import { ScheduleBlock } from '@/blocks/blocks/schedule' import { SearchBlock } from '@/blocks/blocks/search' import { SecretsManagerBlock } from '@/blocks/blocks/secrets_manager' @@ -419,6 +420,7 @@ export const registry: Record = { rss: RssBlock, s3: S3Block, salesforce: SalesforceBlock, + sap_s4hana: SapS4HanaBlock, schedule: ScheduleBlock, search: SearchBlock, sendgrid: SendGridBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 35a34a66777..4f47786f28a 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -4045,6 +4045,7 @@ export function AsanaIcon(props: SVGProps) { } export function PipedriveIcon(props: SVGProps) { + const pathId = useId() return ( ) { ) { fillRule='evenodd' > - - - - + @@ -4098,6 +4096,40 @@ export function SalesforceIcon(props: SVGProps) { ) } +export function SapS4HanaIcon(props: SVGProps) { + const id = useId() + return ( + + + + + + + + + + + + + + ) +} + export function ServiceNowIcon(props: SVGProps) { return ( @@ -4694,15 +4726,16 @@ export function DynamoDBIcon(props: SVGProps) { } export function IAMIcon(props: SVGProps) { + const id = useId() return ( - + - + ) { } export function IdentityCenterIcon(props: SVGProps) { + const id = useId() return ( - + - + ) { } export function STSIcon(props: SVGProps) { + const id = useId() return ( - + - + ) { } export function SESIcon(props: SVGProps) { + const id = useId() return ( - + - + ) { } export function SecretsManagerIcon(props: SVGProps) { + const id = useId() return ( - + - + = { salesforce_query_more: salesforceQueryMoreTool, salesforce_describe_object: salesforceDescribeObjectTool, salesforce_list_objects: salesforceListObjectsTool, + sap_s4hana_create_business_partner: sapS4HanaCreateBusinessPartnerTool, + sap_s4hana_create_purchase_order: sapS4HanaCreatePurchaseOrderTool, + sap_s4hana_create_purchase_requisition: sapS4HanaCreatePurchaseRequisitionTool, + sap_s4hana_create_sales_order: sapS4HanaCreateSalesOrderTool, + sap_s4hana_delete_sales_order: sapS4HanaDeleteSalesOrderTool, + sap_s4hana_get_billing_document: sapS4HanaGetBillingDocumentTool, + sap_s4hana_get_business_partner: sapS4HanaGetBusinessPartnerTool, + sap_s4hana_get_customer: sapS4HanaGetCustomerTool, + sap_s4hana_get_inbound_delivery: sapS4HanaGetInboundDeliveryTool, + sap_s4hana_get_outbound_delivery: sapS4HanaGetOutboundDeliveryTool, + sap_s4hana_get_product: sapS4HanaGetProductTool, + sap_s4hana_get_purchase_order: sapS4HanaGetPurchaseOrderTool, + sap_s4hana_get_purchase_requisition: sapS4HanaGetPurchaseRequisitionTool, + sap_s4hana_get_sales_order: sapS4HanaGetSalesOrderTool, + sap_s4hana_get_supplier: sapS4HanaGetSupplierTool, + sap_s4hana_get_supplier_invoice: sapS4HanaGetSupplierInvoiceTool, + sap_s4hana_list_billing_documents: sapS4HanaListBillingDocumentsTool, + sap_s4hana_list_business_partners: sapS4HanaListBusinessPartnersTool, + sap_s4hana_list_customers: sapS4HanaListCustomersTool, + sap_s4hana_list_inbound_deliveries: sapS4HanaListInboundDeliveriesTool, + sap_s4hana_list_material_documents: sapS4HanaListMaterialDocumentsTool, + sap_s4hana_list_material_stock: sapS4HanaListMaterialStockTool, + sap_s4hana_list_outbound_deliveries: sapS4HanaListOutboundDeliveriesTool, + sap_s4hana_list_products: sapS4HanaListProductsTool, + sap_s4hana_list_purchase_orders: sapS4HanaListPurchaseOrdersTool, + sap_s4hana_list_purchase_requisitions: sapS4HanaListPurchaseRequisitionsTool, + sap_s4hana_list_sales_orders: sapS4HanaListSalesOrdersTool, + sap_s4hana_list_supplier_invoices: sapS4HanaListSupplierInvoicesTool, + sap_s4hana_list_suppliers: sapS4HanaListSuppliersTool, + sap_s4hana_odata_query: sapS4HanaOdataQueryTool, + sap_s4hana_update_business_partner: sapS4HanaUpdateBusinessPartnerTool, + sap_s4hana_update_customer: sapS4HanaUpdateCustomerTool, + sap_s4hana_update_product: sapS4HanaUpdateProductTool, + sap_s4hana_update_purchase_order: sapS4HanaUpdatePurchaseOrderTool, + sap_s4hana_update_purchase_requisition: sapS4HanaUpdatePurchaseRequisitionTool, + sap_s4hana_update_sales_order: sapS4HanaUpdateSalesOrderTool, + sap_s4hana_update_supplier: sapS4HanaUpdateSupplierTool, sqs_send: sqsSendTool, sts_assume_role: stsAssumeRoleTool, sts_get_caller_identity: stsGetCallerIdentityTool, diff --git a/apps/sim/tools/sap_s4hana/create_business_partner.ts b/apps/sim/tools/sap_s4hana/create_business_partner.ts new file mode 100644 index 00000000000..c908a2e118b --- /dev/null +++ b/apps/sim/tools/sap_s4hana/create_business_partner.ts @@ -0,0 +1,162 @@ +import type { CreateBusinessPartnerParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + parseJsonInput, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const createBusinessPartnerTool: ToolConfig = + { + id: 'sap_s4hana_create_business_partner', + name: 'SAP S/4HANA Create Business Partner', + description: + 'Create a business partner in SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_BusinessPartner). For Person category 1 provide FirstName and LastName. For Organization category 2 provide OrganizationBPName1.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + businessPartnerCategory: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'BusinessPartnerCategory: "1" Person, "2" Organization, "3" Group', + }, + businessPartnerGrouping: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'BusinessPartnerGrouping (number range / role grouping configured in S/4HANA, e.g. "0001")', + }, + firstName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'FirstName (required for Person)', + }, + lastName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'LastName (required for Person)', + }, + organizationBPName1: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OrganizationBPName1 (required for Organization)', + }, + body: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Optional additional A_BusinessPartner fields merged into the create payload', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const extra = parseJsonInput>(params.body, 'body') ?? {} + const extraHasName = (key: string) => Object.hasOwn(extra, key) && Boolean(extra[key]) + if (params.businessPartnerCategory === '1') { + const hasFirst = Boolean(params.firstName) || extraHasName('FirstName') + const hasLast = Boolean(params.lastName) || extraHasName('LastName') + if (!hasFirst || !hasLast) { + throw new Error('BusinessPartnerCategory "1" (Person) requires FirstName and LastName') + } + } else if (params.businessPartnerCategory === '2') { + const hasOrgName = + Boolean(params.organizationBPName1) || extraHasName('OrganizationBPName1') + if (!hasOrgName) { + throw new Error( + 'BusinessPartnerCategory "2" (Organization) requires OrganizationBPName1' + ) + } + } + const payload: Record = { + ...extra, + BusinessPartnerCategory: params.businessPartnerCategory, + BusinessPartnerGrouping: params.businessPartnerGrouping, + } + if (params.firstName) payload.FirstName = params.firstName + if (params.lastName) payload.LastName = params.lastName + if (params.organizationBPName1) payload.OrganizationBPName1 = params.organizationBPName1 + return { + ...baseProxyBody(params), + service: 'API_BUSINESS_PARTNER', + path: '/A_BusinessPartner', + method: 'POST', + query: { $format: 'json' }, + body: payload, + } + }, + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Created A_BusinessPartner entity' }, + }, + } diff --git a/apps/sim/tools/sap_s4hana/create_purchase_order.ts b/apps/sim/tools/sap_s4hana/create_purchase_order.ts new file mode 100644 index 00000000000..04dcc8662d7 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/create_purchase_order.ts @@ -0,0 +1,145 @@ +import type { CreatePurchaseOrderParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + parseJsonInput, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const createPurchaseOrderTool: ToolConfig = { + id: 'sap_s4hana_create_purchase_order', + name: 'SAP S/4HANA Create Purchase Order', + description: + 'Create a purchase order in SAP S/4HANA Cloud (API_PURCHASEORDER_PROCESS_SRV, A_PurchaseOrder). PurchaseOrder is auto-assigned by SAP from the document number range; provide line items via the body parameter.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + purchaseOrderType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'PurchaseOrderType (e.g., "NB" Standard PO)', + }, + companyCode: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'CompanyCode (4 chars, e.g., "1010")', + }, + purchasingOrganization: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'PurchasingOrganization (4 chars)', + }, + purchasingGroup: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'PurchasingGroup (3 chars)', + }, + supplier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Supplier business partner key (up to 10 chars)', + }, + body: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Additional A_PurchaseOrder fields and to_PurchaseOrderItem deep-insert items merged into the create payload (e.g., {"to_PurchaseOrderItem":[{"PurchaseOrderItem":"10","Material":"TG11","OrderQuantity":"5","Plant":"1010","PurchaseOrderQuantityUnit":"PC","NetPriceAmount":"100.00","DocumentCurrency":"USD"}]}).', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const extra = parseJsonInput>(params.body, 'body') ?? {} + const payload: Record = { + ...extra, + PurchaseOrderType: params.purchaseOrderType, + CompanyCode: params.companyCode, + PurchasingOrganization: params.purchasingOrganization, + PurchasingGroup: params.purchasingGroup, + Supplier: params.supplier, + } + return { + ...baseProxyBody(params), + service: 'API_PURCHASEORDER_PROCESS_SRV', + path: '/A_PurchaseOrder', + method: 'POST', + query: { $format: 'json' }, + body: payload, + } + }, + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Created A_PurchaseOrder entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/create_purchase_requisition.ts b/apps/sim/tools/sap_s4hana/create_purchase_requisition.ts new file mode 100644 index 00000000000..81b27334d2e --- /dev/null +++ b/apps/sim/tools/sap_s4hana/create_purchase_requisition.ts @@ -0,0 +1,132 @@ +import type { CreatePurchaseRequisitionParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + parseJsonInput, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const createPurchaseRequisitionTool: ToolConfig< + CreatePurchaseRequisitionParams, + SapProxyResponse +> = { + id: 'sap_s4hana_create_purchase_requisition', + name: 'SAP S/4HANA Create Purchase Requisition', + description: + 'Create a purchase requisition in SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV, A_PurchaseRequisitionHeader). PurchaseRequisition is auto-assigned by SAP from the document number range; provide line items via the to_PurchaseReqnItem deep-insert array. Note: API_PURCHASEREQ_PROCESS_SRV is deprecated since S/4HANA Cloud Public Edition 2402; the successor is API_PURCHASEREQUISITION_2 (OData v4). This tool still works against tenants where the legacy service is enabled.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + purchaseRequisitionType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'PurchaseRequisitionType (e.g., "NB" Standard PR)', + }, + items: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'to_PurchaseReqnItem deep-insert array (e.g., [{"PurchaseRequisitionItem":"10","Material":"TG11","RequestedQuantity":"5","Plant":"1010","BaseUnit":"PC","DeliveryDate":"/Date(1735689600000)/"}])', + }, + body: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Additional A_PurchaseRequisitionHeader fields merged into the create payload (e.g., {"PurchaseRequisitionDescription":"Office supplies"})', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const items = parseJsonInput>>(params.items, 'items') + if (!Array.isArray(items) || items.length === 0) { + throw new Error('items must be a non-empty JSON array of purchase requisition items') + } + const extra = parseJsonInput>(params.body, 'body') ?? {} + const payload: Record = { + ...extra, + PurchaseRequisitionType: params.purchaseRequisitionType, + to_PurchaseReqnItem: items, + } + return { + ...baseProxyBody(params), + service: 'API_PURCHASEREQ_PROCESS_SRV', + path: '/A_PurchaseRequisitionHeader', + method: 'POST', + query: { $format: 'json' }, + body: payload, + } + }, + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Created A_PurchaseRequisitionHeader entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/create_sales_order.ts b/apps/sim/tools/sap_s4hana/create_sales_order.ts new file mode 100644 index 00000000000..bac8a43993b --- /dev/null +++ b/apps/sim/tools/sap_s4hana/create_sales_order.ts @@ -0,0 +1,159 @@ +import type { CreateSalesOrderParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + parseJsonInput, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const createSalesOrderTool: ToolConfig = { + id: 'sap_s4hana_create_sales_order', + name: 'SAP S/4HANA Create Sales Order', + description: + 'Create a sales order in SAP S/4HANA Cloud (API_SALES_ORDER_SRV, A_SalesOrder) with deep insert of sales order items via to_Item.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + salesOrderType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'SalesOrderType (e.g., "OR" Standard Order)', + }, + salesOrganization: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'SalesOrganization (4 chars, e.g., "1010")', + }, + distributionChannel: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'DistributionChannel (2 chars, e.g., "10")', + }, + organizationDivision: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'OrganizationDivision (2 chars, e.g., "00")', + }, + soldToParty: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'SoldToParty business partner key (up to 10 chars)', + }, + items: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of sales order items for to_Item deep insert. Each item should include Material and RequestedQuantity (e.g., [{"Material":"TG11","RequestedQuantity":"1"}]).', + }, + body: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Optional additional A_SalesOrder fields merged into the create payload', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const items = parseJsonInput>>(params.items, 'items') + if (!Array.isArray(items) || items.length === 0) { + throw new Error('items must be a non-empty JSON array of sales order item objects') + } + const extra = parseJsonInput>(params.body, 'body') ?? {} + const payload: Record = { + ...extra, + SalesOrderType: params.salesOrderType, + SalesOrganization: params.salesOrganization, + DistributionChannel: params.distributionChannel, + OrganizationDivision: params.organizationDivision, + SoldToParty: params.soldToParty, + to_Item: items, + } + return { + ...baseProxyBody(params), + service: 'API_SALES_ORDER_SRV', + path: '/A_SalesOrder', + method: 'POST', + query: { $format: 'json' }, + body: payload, + } + }, + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { + type: 'json', + description: 'Created A_SalesOrder entity (with deep-inserted items if expanded by SAP)', + }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/delete_sales_order.ts b/apps/sim/tools/sap_s4hana/delete_sales_order.ts new file mode 100644 index 00000000000..a353d288074 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/delete_sales_order.ts @@ -0,0 +1,108 @@ +import type { DeleteSalesOrderParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteSalesOrderTool: ToolConfig = { + id: 'sap_s4hana_delete_sales_order', + name: 'SAP S/4HANA Delete Sales Order', + description: + 'Delete an A_SalesOrder entity in SAP S/4HANA Cloud (API_SALES_ORDER_SRV). Only orders without subsequent documents (deliveries, invoices) can be deleted; otherwise reject items via update instead.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + salesOrder: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'SalesOrder key to delete (string, up to 10 characters)', + }, + ifMatch: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'If-Match ETag for optimistic concurrency. Defaults to "*" (unconditional).', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_SALES_ORDER_SRV', + path: `/A_SalesOrder(${quoteOdataKey(params.salesOrder)})`, + method: 'DELETE', + ifMatch: params.ifMatch || '*', + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP (204 on success)' }, + data: { type: 'json', description: 'Null on successful deletion' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/get_billing_document.ts b/apps/sim/tools/sap_s4hana/get_billing_document.ts new file mode 100644 index 00000000000..7a2d9047acc --- /dev/null +++ b/apps/sim/tools/sap_s4hana/get_billing_document.ts @@ -0,0 +1,115 @@ +import type { GetBillingDocumentParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildEntityQuery, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const getBillingDocumentTool: ToolConfig = { + id: 'sap_s4hana_get_billing_document', + name: 'SAP S/4HANA Get Billing Document', + description: + 'Retrieve a single billing document (customer invoice) by BillingDocument key from SAP S/4HANA Cloud (API_BILLING_DOCUMENT_SRV, A_BillingDocument).', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + billingDocument: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'BillingDocument key (string, up to 10 characters)', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand (e.g., "to_Item,to_Partner")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_BILLING_DOCUMENT_SRV', + path: `/A_BillingDocument(${quoteOdataKey(params.billingDocument)})`, + method: 'GET', + query: buildEntityQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'A_BillingDocument entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/get_business_partner.ts b/apps/sim/tools/sap_s4hana/get_business_partner.ts new file mode 100644 index 00000000000..c7f5d10200d --- /dev/null +++ b/apps/sim/tools/sap_s4hana/get_business_partner.ts @@ -0,0 +1,115 @@ +import type { GetBusinessPartnerParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildEntityQuery, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const getBusinessPartnerTool: ToolConfig = { + id: 'sap_s4hana_get_business_partner', + name: 'SAP S/4HANA Get Business Partner', + description: + 'Retrieve a single business partner by BusinessPartner key from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_BusinessPartner).', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + businessPartner: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'BusinessPartner key (string, up to 10 characters)', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand ($expand)', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_BUSINESS_PARTNER', + path: `/A_BusinessPartner(${quoteOdataKey(params.businessPartner)})`, + method: 'GET', + query: buildEntityQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'A_BusinessPartner entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/get_customer.ts b/apps/sim/tools/sap_s4hana/get_customer.ts new file mode 100644 index 00000000000..ca03c2e4383 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/get_customer.ts @@ -0,0 +1,116 @@ +import type { GetCustomerParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildEntityQuery, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const getCustomerTool: ToolConfig = { + id: 'sap_s4hana_get_customer', + name: 'SAP S/4HANA Get Customer', + description: + 'Retrieve a single customer by Customer key from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_Customer).', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + customer: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Customer key (string, up to 10 characters)', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated navigation properties to expand (e.g., "to_CustomerCompany,to_CustomerSalesArea")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_BUSINESS_PARTNER', + path: `/A_Customer(${quoteOdataKey(params.customer)})`, + method: 'GET', + query: buildEntityQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'A_Customer entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/get_inbound_delivery.ts b/apps/sim/tools/sap_s4hana/get_inbound_delivery.ts new file mode 100644 index 00000000000..78d78a24596 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/get_inbound_delivery.ts @@ -0,0 +1,116 @@ +import type { GetInboundDeliveryParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildEntityQuery, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const getInboundDeliveryTool: ToolConfig = { + id: 'sap_s4hana_get_inbound_delivery', + name: 'SAP S/4HANA Get Inbound Delivery', + description: + 'Retrieve a single inbound delivery by DeliveryDocument key from SAP S/4HANA Cloud (API_INBOUND_DELIVERY_SRV;v=0002, A_InbDeliveryHeader).', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + deliveryDocument: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'DeliveryDocument key (string, up to 10 characters)', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated navigation properties to expand (e.g., "to_DeliveryDocumentItem")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_INBOUND_DELIVERY_SRV;v=0002', + path: `/A_InbDeliveryHeader(${quoteOdataKey(params.deliveryDocument)})`, + method: 'GET', + query: buildEntityQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'A_InbDeliveryHeader entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/get_outbound_delivery.ts b/apps/sim/tools/sap_s4hana/get_outbound_delivery.ts new file mode 100644 index 00000000000..eaa90818e34 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/get_outbound_delivery.ts @@ -0,0 +1,116 @@ +import type { GetOutboundDeliveryParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildEntityQuery, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const getOutboundDeliveryTool: ToolConfig = { + id: 'sap_s4hana_get_outbound_delivery', + name: 'SAP S/4HANA Get Outbound Delivery', + description: + 'Retrieve a single outbound delivery by DeliveryDocument key from SAP S/4HANA Cloud (API_OUTBOUND_DELIVERY_SRV;v=0002, A_OutbDeliveryHeader).', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + deliveryDocument: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'DeliveryDocument key (string, up to 10 characters)', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated navigation properties to expand (e.g., "to_DeliveryDocumentItem")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_OUTBOUND_DELIVERY_SRV;v=0002', + path: `/A_OutbDeliveryHeader(${quoteOdataKey(params.deliveryDocument)})`, + method: 'GET', + query: buildEntityQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'A_OutbDeliveryHeader entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/get_product.ts b/apps/sim/tools/sap_s4hana/get_product.ts new file mode 100644 index 00000000000..c9c9cd13d41 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/get_product.ts @@ -0,0 +1,115 @@ +import type { GetProductParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildEntityQuery, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const getProductTool: ToolConfig = { + id: 'sap_s4hana_get_product', + name: 'SAP S/4HANA Get Product', + description: + 'Retrieve a single product (material) by Product key from SAP S/4HANA Cloud (API_PRODUCT_SRV, A_Product).', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + product: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Product key (string, up to 40 characters)', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand (e.g., "to_Description")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_PRODUCT_SRV', + path: `/A_Product(${quoteOdataKey(params.product)})`, + method: 'GET', + query: buildEntityQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'A_Product entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/get_purchase_order.ts b/apps/sim/tools/sap_s4hana/get_purchase_order.ts new file mode 100644 index 00000000000..3a97e272113 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/get_purchase_order.ts @@ -0,0 +1,115 @@ +import type { GetPurchaseOrderParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildEntityQuery, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const getPurchaseOrderTool: ToolConfig = { + id: 'sap_s4hana_get_purchase_order', + name: 'SAP S/4HANA Get Purchase Order', + description: + 'Retrieve a single purchase order by PurchaseOrder key from SAP S/4HANA Cloud (API_PURCHASEORDER_PROCESS_SRV, A_PurchaseOrder).', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + purchaseOrder: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'PurchaseOrder key (string, up to 10 characters)', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand (e.g., "to_PurchaseOrderItem")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_PURCHASEORDER_PROCESS_SRV', + path: `/A_PurchaseOrder(${quoteOdataKey(params.purchaseOrder)})`, + method: 'GET', + query: buildEntityQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'A_PurchaseOrder entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/get_purchase_requisition.ts b/apps/sim/tools/sap_s4hana/get_purchase_requisition.ts new file mode 100644 index 00000000000..518f249948a --- /dev/null +++ b/apps/sim/tools/sap_s4hana/get_purchase_requisition.ts @@ -0,0 +1,118 @@ +import type { GetPurchaseRequisitionParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildEntityQuery, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const getPurchaseRequisitionTool: ToolConfig< + GetPurchaseRequisitionParams, + SapProxyResponse +> = { + id: 'sap_s4hana_get_purchase_requisition', + name: 'SAP S/4HANA Get Purchase Requisition', + description: + 'Retrieve a single purchase requisition by PurchaseRequisition key from SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV, A_PurchaseRequisitionHeader). Note: API_PURCHASEREQ_PROCESS_SRV is deprecated since S/4HANA Cloud Public Edition 2402; the successor is API_PURCHASEREQUISITION_2 (OData v4). This tool still works against tenants where the legacy service is enabled.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + purchaseRequisition: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'PurchaseRequisition key (string, up to 10 characters)', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand (e.g., "to_PurchaseReqnItem")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_PURCHASEREQ_PROCESS_SRV', + path: `/A_PurchaseRequisitionHeader(${quoteOdataKey(params.purchaseRequisition)})`, + method: 'GET', + query: buildEntityQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'A_PurchaseRequisitionHeader entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/get_sales_order.ts b/apps/sim/tools/sap_s4hana/get_sales_order.ts new file mode 100644 index 00000000000..f1645820814 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/get_sales_order.ts @@ -0,0 +1,115 @@ +import type { GetSalesOrderParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildEntityQuery, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const getSalesOrderTool: ToolConfig = { + id: 'sap_s4hana_get_sales_order', + name: 'SAP S/4HANA Get Sales Order', + description: + 'Retrieve a single sales order by SalesOrder key from SAP S/4HANA Cloud (API_SALES_ORDER_SRV, A_SalesOrder).', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + salesOrder: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'SalesOrder key (string, up to 10 characters)', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand (e.g., "to_Item")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_SALES_ORDER_SRV', + path: `/A_SalesOrder(${quoteOdataKey(params.salesOrder)})`, + method: 'GET', + query: buildEntityQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'A_SalesOrder entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/get_supplier.ts b/apps/sim/tools/sap_s4hana/get_supplier.ts new file mode 100644 index 00000000000..ebe31ade5a0 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/get_supplier.ts @@ -0,0 +1,116 @@ +import type { GetSupplierParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildEntityQuery, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const getSupplierTool: ToolConfig = { + id: 'sap_s4hana_get_supplier', + name: 'SAP S/4HANA Get Supplier', + description: + 'Retrieve a single supplier by Supplier key from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_Supplier).', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + supplier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Supplier key (string, up to 10 characters)', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated navigation properties to expand (e.g., "to_SupplierCompany,to_SupplierPurchasingOrg")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_BUSINESS_PARTNER', + path: `/A_Supplier(${quoteOdataKey(params.supplier)})`, + method: 'GET', + query: buildEntityQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'A_Supplier entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/get_supplier_invoice.ts b/apps/sim/tools/sap_s4hana/get_supplier_invoice.ts new file mode 100644 index 00000000000..9e5c3ac9537 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/get_supplier_invoice.ts @@ -0,0 +1,121 @@ +import type { GetSupplierInvoiceParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildEntityQuery, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const getSupplierInvoiceTool: ToolConfig = { + id: 'sap_s4hana_get_supplier_invoice', + name: 'SAP S/4HANA Get Supplier Invoice', + description: + 'Retrieve a single supplier invoice by composite key (SupplierInvoice + FiscalYear) from SAP S/4HANA Cloud (API_SUPPLIERINVOICE_PROCESS_SRV, A_SupplierInvoice).', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + supplierInvoice: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'SupplierInvoice key (string, up to 10 characters)', + }, + fiscalYear: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'FiscalYear (4-character year, e.g., "2024")', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand ($expand)', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_SUPPLIERINVOICE_PROCESS_SRV', + path: `/A_SupplierInvoice(SupplierInvoice=${quoteOdataKey(params.supplierInvoice)},FiscalYear=${quoteOdataKey(params.fiscalYear)})`, + method: 'GET', + query: buildEntityQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'A_SupplierInvoice entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/index.ts b/apps/sim/tools/sap_s4hana/index.ts new file mode 100644 index 00000000000..0115fdfe7c1 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/index.ts @@ -0,0 +1,37 @@ +export { createBusinessPartnerTool } from '@/tools/sap_s4hana/create_business_partner' +export { createPurchaseOrderTool } from '@/tools/sap_s4hana/create_purchase_order' +export { createPurchaseRequisitionTool } from '@/tools/sap_s4hana/create_purchase_requisition' +export { createSalesOrderTool } from '@/tools/sap_s4hana/create_sales_order' +export { deleteSalesOrderTool } from '@/tools/sap_s4hana/delete_sales_order' +export { getBillingDocumentTool } from '@/tools/sap_s4hana/get_billing_document' +export { getBusinessPartnerTool } from '@/tools/sap_s4hana/get_business_partner' +export { getCustomerTool } from '@/tools/sap_s4hana/get_customer' +export { getInboundDeliveryTool } from '@/tools/sap_s4hana/get_inbound_delivery' +export { getOutboundDeliveryTool } from '@/tools/sap_s4hana/get_outbound_delivery' +export { getProductTool } from '@/tools/sap_s4hana/get_product' +export { getPurchaseOrderTool } from '@/tools/sap_s4hana/get_purchase_order' +export { getPurchaseRequisitionTool } from '@/tools/sap_s4hana/get_purchase_requisition' +export { getSalesOrderTool } from '@/tools/sap_s4hana/get_sales_order' +export { getSupplierTool } from '@/tools/sap_s4hana/get_supplier' +export { getSupplierInvoiceTool } from '@/tools/sap_s4hana/get_supplier_invoice' +export { listBillingDocumentsTool } from '@/tools/sap_s4hana/list_billing_documents' +export { listBusinessPartnersTool } from '@/tools/sap_s4hana/list_business_partners' +export { listCustomersTool } from '@/tools/sap_s4hana/list_customers' +export { listInboundDeliveriesTool } from '@/tools/sap_s4hana/list_inbound_deliveries' +export { listMaterialDocumentsTool } from '@/tools/sap_s4hana/list_material_documents' +export { listMaterialStockTool } from '@/tools/sap_s4hana/list_material_stock' +export { listOutboundDeliveriesTool } from '@/tools/sap_s4hana/list_outbound_deliveries' +export { listProductsTool } from '@/tools/sap_s4hana/list_products' +export { listPurchaseOrdersTool } from '@/tools/sap_s4hana/list_purchase_orders' +export { listPurchaseRequisitionsTool } from '@/tools/sap_s4hana/list_purchase_requisitions' +export { listSalesOrdersTool } from '@/tools/sap_s4hana/list_sales_orders' +export { listSupplierInvoicesTool } from '@/tools/sap_s4hana/list_supplier_invoices' +export { listSuppliersTool } from '@/tools/sap_s4hana/list_suppliers' +export { odataQueryTool } from '@/tools/sap_s4hana/odata_query' +export { updateBusinessPartnerTool } from '@/tools/sap_s4hana/update_business_partner' +export { updateCustomerTool } from '@/tools/sap_s4hana/update_customer' +export { updateProductTool } from '@/tools/sap_s4hana/update_product' +export { updatePurchaseOrderTool } from '@/tools/sap_s4hana/update_purchase_order' +export { updatePurchaseRequisitionTool } from '@/tools/sap_s4hana/update_purchase_requisition' +export { updateSalesOrderTool } from '@/tools/sap_s4hana/update_sales_order' +export { updateSupplierTool } from '@/tools/sap_s4hana/update_supplier' diff --git a/apps/sim/tools/sap_s4hana/list_billing_documents.ts b/apps/sim/tools/sap_s4hana/list_billing_documents.ts new file mode 100644 index 00000000000..dfba1c08d9b --- /dev/null +++ b/apps/sim/tools/sap_s4hana/list_billing_documents.ts @@ -0,0 +1,132 @@ +import type { ListBillingDocumentsParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildOdataQuery, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const listBillingDocumentsTool: ToolConfig = { + id: 'sap_s4hana_list_billing_documents', + name: 'SAP S/4HANA List Billing Documents', + description: + 'List billing documents (customer invoices) from SAP S/4HANA Cloud (API_BILLING_DOCUMENT_SRV, A_BillingDocument) with optional OData $filter, $top, $skip, $orderby, $select, $expand.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $filter expression (e.g., "SoldToParty eq \'10100001\'")', + }, + top: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return ($top)', + }, + skip: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip ($skip)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $orderby expression', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand (e.g., "to_Item,to_Partner")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_BILLING_DOCUMENT_SRV', + path: '/A_BillingDocument', + method: 'GET', + query: buildOdataQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Array of A_BillingDocument entities' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/list_business_partners.ts b/apps/sim/tools/sap_s4hana/list_business_partners.ts new file mode 100644 index 00000000000..4f90446e4d1 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/list_business_partners.ts @@ -0,0 +1,132 @@ +import type { ListBusinessPartnersParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildOdataQuery, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const listBusinessPartnersTool: ToolConfig = { + id: 'sap_s4hana_list_business_partners', + name: 'SAP S/4HANA List Business Partners', + description: + 'List business partners from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_BusinessPartner) with optional OData $filter, $top, $skip, $orderby, $select, $expand.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $filter expression (e.g., "BusinessPartnerCategory eq \'1\'")', + }, + top: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return ($top)', + }, + skip: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip ($skip)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $orderby expression', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand ($expand)', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_BUSINESS_PARTNER', + path: '/A_BusinessPartner', + method: 'GET', + query: buildOdataQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Array of A_BusinessPartner entities' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/list_customers.ts b/apps/sim/tools/sap_s4hana/list_customers.ts new file mode 100644 index 00000000000..1cc7f6a34ac --- /dev/null +++ b/apps/sim/tools/sap_s4hana/list_customers.ts @@ -0,0 +1,133 @@ +import type { ListCustomersParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildOdataQuery, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const listCustomersTool: ToolConfig = { + id: 'sap_s4hana_list_customers', + name: 'SAP S/4HANA List Customers', + description: + 'List customers from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_Customer) with optional OData $filter, $top, $skip, $orderby, $select, $expand.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $filter expression (e.g., "CustomerAccountGroup eq \'Z001\'")', + }, + top: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return ($top)', + }, + skip: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip ($skip)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $orderby expression', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated navigation properties to expand (e.g., "to_CustomerCompany,to_CustomerSalesArea")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_BUSINESS_PARTNER', + path: '/A_Customer', + method: 'GET', + query: buildOdataQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Array of A_Customer entities' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/list_inbound_deliveries.ts b/apps/sim/tools/sap_s4hana/list_inbound_deliveries.ts new file mode 100644 index 00000000000..b04a905b0d2 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/list_inbound_deliveries.ts @@ -0,0 +1,134 @@ +import type { ListInboundDeliveriesParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildOdataQuery, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const listInboundDeliveriesTool: ToolConfig = + { + id: 'sap_s4hana_list_inbound_deliveries', + name: 'SAP S/4HANA List Inbound Deliveries', + description: + 'List inbound deliveries from SAP S/4HANA Cloud (API_INBOUND_DELIVERY_SRV;v=0002, A_InbDeliveryHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $filter expression (e.g., "ReceivingPlant eq \'1010\'")', + }, + top: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return ($top)', + }, + skip: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip ($skip)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $orderby expression', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated navigation properties to expand (e.g., "to_DeliveryDocumentItem")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_INBOUND_DELIVERY_SRV;v=0002', + path: '/A_InbDeliveryHeader', + method: 'GET', + query: buildOdataQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Array of A_InbDeliveryHeader entities' }, + }, + } diff --git a/apps/sim/tools/sap_s4hana/list_material_documents.ts b/apps/sim/tools/sap_s4hana/list_material_documents.ts new file mode 100644 index 00000000000..6f9a254e6b6 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/list_material_documents.ts @@ -0,0 +1,135 @@ +import type { ListMaterialDocumentsParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildOdataQuery, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const listMaterialDocumentsTool: ToolConfig = + { + id: 'sap_s4hana_list_material_documents', + name: 'SAP S/4HANA List Material Documents', + description: + 'List material document headers (goods movements) from SAP S/4HANA Cloud (API_MATERIAL_DOCUMENT_SRV, A_MaterialDocumentHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + "OData $filter expression (e.g., \"MaterialDocumentYear eq '2024' and PostingDate ge datetime'2024-01-01T00:00:00'\")", + }, + top: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return ($top)', + }, + skip: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip ($skip)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $orderby expression', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated navigation properties to expand (e.g., "to_MaterialDocumentItem")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_MATERIAL_DOCUMENT_SRV', + path: '/A_MaterialDocumentHeader', + method: 'GET', + query: buildOdataQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Array of A_MaterialDocumentHeader entities' }, + }, + } diff --git a/apps/sim/tools/sap_s4hana/list_material_stock.ts b/apps/sim/tools/sap_s4hana/list_material_stock.ts new file mode 100644 index 00000000000..0d5159a1f0b --- /dev/null +++ b/apps/sim/tools/sap_s4hana/list_material_stock.ts @@ -0,0 +1,133 @@ +import type { ListMaterialStockParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildOdataQuery, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const listMaterialStockTool: ToolConfig = { + id: 'sap_s4hana_list_material_stock', + name: 'SAP S/4HANA List Material Stock', + description: + 'List material stock quantities from SAP S/4HANA Cloud (API_MATERIAL_STOCK_SRV, A_MatlStkInAcctMod). The entity uses an 11-field composite key (Material, Plant, StorageLocation, Batch, Supplier, Customer, WBSElementInternalID, SDDocument, SDDocumentItem, InventorySpecialStockType, InventoryStockType) — query with $filter on these fields instead of a direct key lookup.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + "OData $filter expression (e.g., \"Material eq 'TG10' and Plant eq '1010' and InventoryStockType eq '01'\")", + }, + top: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return ($top)', + }, + skip: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip ($skip)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $orderby expression', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand ($expand)', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_MATERIAL_STOCK_SRV', + path: '/A_MatlStkInAcctMod', + method: 'GET', + query: buildOdataQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Array of A_MatlStkInAcctMod stock entries' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/list_outbound_deliveries.ts b/apps/sim/tools/sap_s4hana/list_outbound_deliveries.ts new file mode 100644 index 00000000000..a284133b009 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/list_outbound_deliveries.ts @@ -0,0 +1,136 @@ +import type { ListOutboundDeliveriesParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildOdataQuery, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const listOutboundDeliveriesTool: ToolConfig< + ListOutboundDeliveriesParams, + SapProxyResponse +> = { + id: 'sap_s4hana_list_outbound_deliveries', + name: 'SAP S/4HANA List Outbound Deliveries', + description: + 'List outbound deliveries from SAP S/4HANA Cloud (API_OUTBOUND_DELIVERY_SRV;v=0002, A_OutbDeliveryHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $filter expression (e.g., "OverallDeliveryStatus eq \'C\'")', + }, + top: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return ($top)', + }, + skip: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip ($skip)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $orderby expression', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated navigation properties to expand (e.g., "to_DeliveryDocumentItem")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_OUTBOUND_DELIVERY_SRV;v=0002', + path: '/A_OutbDeliveryHeader', + method: 'GET', + query: buildOdataQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Array of A_OutbDeliveryHeader entities' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/list_products.ts b/apps/sim/tools/sap_s4hana/list_products.ts new file mode 100644 index 00000000000..3d624e2f782 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/list_products.ts @@ -0,0 +1,132 @@ +import type { ListProductsParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildOdataQuery, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const listProductsTool: ToolConfig = { + id: 'sap_s4hana_list_products', + name: 'SAP S/4HANA List Products', + description: + 'List products (materials) from SAP S/4HANA Cloud (API_PRODUCT_SRV, A_Product) with optional OData $filter, $top, $skip, $orderby, $select, $expand.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $filter expression (e.g., "ProductType eq \'FERT\'")', + }, + top: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return ($top)', + }, + skip: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip ($skip)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $orderby expression', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand ($expand)', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_PRODUCT_SRV', + path: '/A_Product', + method: 'GET', + query: buildOdataQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Array of A_Product entities' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/list_purchase_orders.ts b/apps/sim/tools/sap_s4hana/list_purchase_orders.ts new file mode 100644 index 00000000000..f3e2c7778d9 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/list_purchase_orders.ts @@ -0,0 +1,132 @@ +import type { ListPurchaseOrdersParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildOdataQuery, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const listPurchaseOrdersTool: ToolConfig = { + id: 'sap_s4hana_list_purchase_orders', + name: 'SAP S/4HANA List Purchase Orders', + description: + 'List purchase orders from SAP S/4HANA Cloud (API_PURCHASEORDER_PROCESS_SRV, A_PurchaseOrder) with optional OData $filter, $top, $skip, $orderby, $select, $expand.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $filter expression (e.g., "CompanyCode eq \'1010\'")', + }, + top: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return ($top)', + }, + skip: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip ($skip)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $orderby expression', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand (e.g., "to_PurchaseOrderItem")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_PURCHASEORDER_PROCESS_SRV', + path: '/A_PurchaseOrder', + method: 'GET', + query: buildOdataQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Array of A_PurchaseOrder entities' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/list_purchase_requisitions.ts b/apps/sim/tools/sap_s4hana/list_purchase_requisitions.ts new file mode 100644 index 00000000000..88f99c71b1a --- /dev/null +++ b/apps/sim/tools/sap_s4hana/list_purchase_requisitions.ts @@ -0,0 +1,135 @@ +import type { ListPurchaseRequisitionsParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildOdataQuery, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const listPurchaseRequisitionsTool: ToolConfig< + ListPurchaseRequisitionsParams, + SapProxyResponse +> = { + id: 'sap_s4hana_list_purchase_requisitions', + name: 'SAP S/4HANA List Purchase Requisitions', + description: + 'List purchase requisitions from SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV, A_PurchaseRequisitionHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand. Note: API_PURCHASEREQ_PROCESS_SRV is deprecated since S/4HANA Cloud Public Edition 2402; the successor is API_PURCHASEREQUISITION_2 (OData v4). This tool still works against tenants where the legacy service is enabled.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $filter expression (e.g., "PurchaseRequisitionType eq \'NB\'")', + }, + top: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return ($top)', + }, + skip: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip ($skip)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $orderby expression', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand (e.g., "to_PurchaseReqnItem")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_PURCHASEREQ_PROCESS_SRV', + path: '/A_PurchaseRequisitionHeader', + method: 'GET', + query: buildOdataQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Array of A_PurchaseRequisitionHeader entities' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/list_sales_orders.ts b/apps/sim/tools/sap_s4hana/list_sales_orders.ts new file mode 100644 index 00000000000..75f795fac9b --- /dev/null +++ b/apps/sim/tools/sap_s4hana/list_sales_orders.ts @@ -0,0 +1,132 @@ +import type { ListSalesOrdersParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildOdataQuery, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const listSalesOrdersTool: ToolConfig = { + id: 'sap_s4hana_list_sales_orders', + name: 'SAP S/4HANA List Sales Orders', + description: + 'List sales orders from SAP S/4HANA Cloud (API_SALES_ORDER_SRV, A_SalesOrder) with optional OData $filter, $top, $skip, $orderby, $select, $expand.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $filter expression (e.g., "SalesOrganization eq \'1010\'")', + }, + top: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return ($top)', + }, + skip: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip ($skip)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $orderby expression', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand (e.g., "to_Item,to_Partner")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_SALES_ORDER_SRV', + path: '/A_SalesOrder', + method: 'GET', + query: buildOdataQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Array of A_SalesOrder entities' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/list_supplier_invoices.ts b/apps/sim/tools/sap_s4hana/list_supplier_invoices.ts new file mode 100644 index 00000000000..2415509f95e --- /dev/null +++ b/apps/sim/tools/sap_s4hana/list_supplier_invoices.ts @@ -0,0 +1,132 @@ +import type { ListSupplierInvoicesParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildOdataQuery, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const listSupplierInvoicesTool: ToolConfig = { + id: 'sap_s4hana_list_supplier_invoices', + name: 'SAP S/4HANA List Supplier Invoices', + description: + 'List supplier invoices from SAP S/4HANA Cloud (API_SUPPLIERINVOICE_PROCESS_SRV, A_SupplierInvoice) with optional OData $filter, $top, $skip, $orderby, $select, $expand.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $filter expression (e.g., "InvoicingParty eq \'17300001\'")', + }, + top: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return ($top)', + }, + skip: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip ($skip)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $orderby expression', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated navigation properties to expand ($expand)', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_SUPPLIERINVOICE_PROCESS_SRV', + path: '/A_SupplierInvoice', + method: 'GET', + query: buildOdataQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Array of A_SupplierInvoice entities' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/list_suppliers.ts b/apps/sim/tools/sap_s4hana/list_suppliers.ts new file mode 100644 index 00000000000..cd22519973f --- /dev/null +++ b/apps/sim/tools/sap_s4hana/list_suppliers.ts @@ -0,0 +1,133 @@ +import type { ListSuppliersParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildOdataQuery, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const listSuppliersTool: ToolConfig = { + id: 'sap_s4hana_list_suppliers', + name: 'SAP S/4HANA List Suppliers', + description: + 'List suppliers from SAP S/4HANA Cloud (API_BUSINESS_PARTNER, A_Supplier) with optional OData $filter, $top, $skip, $orderby, $select, $expand.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $filter expression (e.g., "SupplierAccountGroup eq \'BP02\'")', + }, + top: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum results to return ($top)', + }, + skip: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip ($skip)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'OData $orderby expression', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated navigation properties to expand (e.g., "to_SupplierCompany,to_SupplierPurchasingOrg")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_BUSINESS_PARTNER', + path: '/A_Supplier', + method: 'GET', + query: buildOdataQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'Array of A_Supplier entities' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/odata_query.ts b/apps/sim/tools/sap_s4hana/odata_query.ts new file mode 100644 index 00000000000..770eb42ef29 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/odata_query.ts @@ -0,0 +1,163 @@ +import type { ODataQueryParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + parseJsonInput, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +function normalizeQuery( + query: ODataQueryParams['query'] +): Record | undefined { + if (!query) return undefined + if (typeof query === 'object') return query + if (typeof query !== 'string') return undefined + const trimmed = query.trim() + if (!trimmed) return undefined + if (trimmed.startsWith('{')) { + return parseJsonInput>(trimmed, 'query') + } + const search = new URLSearchParams(trimmed.startsWith('?') ? trimmed.slice(1) : trimmed) + const result: Record = {} + for (const [key, value] of search.entries()) result[key] = value + return result +} + +export const odataQueryTool: ToolConfig = { + id: 'sap_s4hana_odata_query', + name: 'SAP S/4HANA OData Query', + description: + 'Make an arbitrary OData v2 call against any SAP S/4HANA Cloud whitelisted Communication Scenario. Use when no dedicated tool exists for the entity. The proxy handles auth, CSRF, and OData unwrapping.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + service: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'OData service name (e.g., "API_BUSINESS_PARTNER", "API_SALES_ORDER_SRV")', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Path inside the service (e.g., "/A_BusinessPartner" or "/A_BusinessPartner(\'1000123\')")', + }, + method: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'HTTP method: GET (default), POST, PATCH, PUT, DELETE, MERGE', + }, + query: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'OData query parameters as JSON object or query string (e.g., {"$filter":"BusinessPartnerCategory eq \'1\'","$top":10}). $format=json is added automatically when omitted.', + }, + body: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON request body for write operations', + }, + ifMatch: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ETag value for the If-Match header (required by SAP for PATCH/PUT/DELETE on existing entities)', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const query = normalizeQuery(params.query) ?? {} + if (!('$format' in query)) query.$format = 'json' + const requestBody: Record = { + ...baseProxyBody(params), + service: params.service, + path: params.path, + method: params.method || 'GET', + query, + } + const parsedBody = parseJsonInput>(params.body, 'body') + if (parsedBody !== undefined) requestBody.body = parsedBody + if (params.ifMatch) requestBody.ifMatch = params.ifMatch + return requestBody + }, + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { + type: 'json', + description: 'Parsed OData payload (entity, collection, or null on 204)', + }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/types.ts b/apps/sim/tools/sap_s4hana/types.ts new file mode 100644 index 00000000000..c8103a212e5 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/types.ts @@ -0,0 +1,302 @@ +import type { ToolResponse } from '@/tools/types' + +export type SapDeploymentType = 'cloud_public' | 'cloud_private' | 'on_premise' +export type SapAuthType = 'oauth_client_credentials' | 'basic' + +export interface SapBaseParams { + deploymentType?: SapDeploymentType + authType?: SapAuthType + subdomain?: string + region?: string + baseUrl?: string + tokenUrl?: string + clientId?: string + clientSecret?: string + username?: string + password?: string +} + +export interface ProxyOutput { + status: number + data: unknown +} + +export interface SapProxyResponse extends ToolResponse { + output: ProxyOutput +} + +export interface ListBusinessPartnersParams extends SapBaseParams { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +} + +export interface GetBusinessPartnerParams extends SapBaseParams { + businessPartner: string + select?: string + expand?: string +} + +export interface CreateBusinessPartnerParams extends SapBaseParams { + businessPartnerCategory: string + businessPartnerGrouping: string + firstName?: string + lastName?: string + organizationBPName1?: string + body?: Record | string +} + +export interface ListSalesOrdersParams extends SapBaseParams { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +} + +export interface GetSalesOrderParams extends SapBaseParams { + salesOrder: string + select?: string + expand?: string +} + +export interface CreateSalesOrderParams extends SapBaseParams { + salesOrderType: string + salesOrganization: string + distributionChannel: string + organizationDivision: string + soldToParty: string + items: string | Array> + body?: Record | string +} + +export interface ListProductsParams extends SapBaseParams { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +} + +export interface GetProductParams extends SapBaseParams { + product: string + select?: string + expand?: string +} + +export interface ListPurchaseOrdersParams extends SapBaseParams { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +} + +export interface GetPurchaseOrderParams extends SapBaseParams { + purchaseOrder: string + select?: string + expand?: string +} + +export interface CreatePurchaseOrderParams extends SapBaseParams { + purchaseOrderType: string + companyCode: string + purchasingOrganization: string + purchasingGroup: string + supplier: string + body?: Record | string +} + +export interface ListSupplierInvoicesParams extends SapBaseParams { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +} + +export interface GetSupplierInvoiceParams extends SapBaseParams { + supplierInvoice: string + fiscalYear: string + select?: string + expand?: string +} + +export interface ListOutboundDeliveriesParams extends SapBaseParams { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +} + +export interface GetOutboundDeliveryParams extends SapBaseParams { + deliveryDocument: string + select?: string + expand?: string +} + +export interface ListBillingDocumentsParams extends SapBaseParams { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +} + +export interface GetBillingDocumentParams extends SapBaseParams { + billingDocument: string + select?: string + expand?: string +} + +export interface ListPurchaseRequisitionsParams extends SapBaseParams { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +} + +export interface GetPurchaseRequisitionParams extends SapBaseParams { + purchaseRequisition: string + select?: string + expand?: string +} + +export interface ListMaterialStockParams extends SapBaseParams { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +} + +export interface ListSuppliersParams extends SapBaseParams { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +} + +export interface GetSupplierParams extends SapBaseParams { + supplier: string + select?: string + expand?: string +} + +export interface ListCustomersParams extends SapBaseParams { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +} + +export interface GetCustomerParams extends SapBaseParams { + customer: string + select?: string + expand?: string +} + +export interface ListInboundDeliveriesParams extends SapBaseParams { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +} + +export interface GetInboundDeliveryParams extends SapBaseParams { + deliveryDocument: string + select?: string + expand?: string +} + +export interface ListMaterialDocumentsParams extends SapBaseParams { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +} + +export interface UpdateBusinessPartnerParams extends SapBaseParams { + businessPartner: string + body: Record | string + ifMatch?: string +} + +export interface UpdateCustomerParams extends SapBaseParams { + customer: string + body: Record | string + ifMatch?: string +} + +export interface UpdateSupplierParams extends SapBaseParams { + supplier: string + body: Record | string + ifMatch?: string +} + +export interface UpdateProductParams extends SapBaseParams { + product: string + body: Record | string + ifMatch?: string +} + +export interface UpdateSalesOrderParams extends SapBaseParams { + salesOrder: string + body: Record | string + ifMatch?: string +} + +export interface DeleteSalesOrderParams extends SapBaseParams { + salesOrder: string + ifMatch?: string +} + +export interface UpdatePurchaseOrderParams extends SapBaseParams { + purchaseOrder: string + body: Record | string + ifMatch?: string +} + +export interface UpdatePurchaseRequisitionParams extends SapBaseParams { + purchaseRequisition: string + body: Record | string + ifMatch?: string +} + +export interface CreatePurchaseRequisitionParams extends SapBaseParams { + purchaseRequisitionType: string + items: string | Array> + body?: Record | string +} + +export type ODataMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' | 'MERGE' + +export interface ODataQueryParams extends SapBaseParams { + service: string + path: string + method?: ODataMethod + query?: string | Record + body?: Record | string + ifMatch?: string +} diff --git a/apps/sim/tools/sap_s4hana/update_business_partner.ts b/apps/sim/tools/sap_s4hana/update_business_partner.ts new file mode 100644 index 00000000000..551391bb27d --- /dev/null +++ b/apps/sim/tools/sap_s4hana/update_business_partner.ts @@ -0,0 +1,128 @@ +import type { SapProxyResponse, UpdateBusinessPartnerParams } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + parseJsonInput, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateBusinessPartnerTool: ToolConfig = + { + id: 'sap_s4hana_update_business_partner', + name: 'SAP S/4HANA Update Business Partner', + description: + 'Update fields on an A_BusinessPartner entity in SAP S/4HANA Cloud (API_BUSINESS_PARTNER). PATCH only sends the fields you provide; existing values are preserved. If-Match defaults to a wildcard (unconditional) — for safe concurrent updates pass the ETag from a prior GET to avoid lost updates.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + businessPartner: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'BusinessPartner key to update (string, up to 10 characters)', + }, + body: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'JSON object with A_BusinessPartner fields to update (e.g., {"FirstName":"Jane","SearchTerm1":"VIP"})', + }, + ifMatch: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'If-Match ETag for optimistic concurrency. Defaults to "*" (unconditional).', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const payload = parseJsonInput>(params.body, 'body') + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('body must be a JSON object with the fields to update') + } + return { + ...baseProxyBody(params), + service: 'API_BUSINESS_PARTNER', + path: `/A_BusinessPartner(${quoteOdataKey(params.businessPartner)})`, + method: 'PATCH', + query: { $format: 'json' }, + body: payload, + ifMatch: params.ifMatch || '*', + } + }, + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP (204 on success)' }, + data: { + type: 'json', + description: 'Null on 204 success, or updated A_BusinessPartner entity if SAP returns one', + }, + }, + } diff --git a/apps/sim/tools/sap_s4hana/update_customer.ts b/apps/sim/tools/sap_s4hana/update_customer.ts new file mode 100644 index 00000000000..4c0373764aa --- /dev/null +++ b/apps/sim/tools/sap_s4hana/update_customer.ts @@ -0,0 +1,127 @@ +import type { SapProxyResponse, UpdateCustomerParams } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + parseJsonInput, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateCustomerTool: ToolConfig = { + id: 'sap_s4hana_update_customer', + name: 'SAP S/4HANA Update Customer', + description: + 'Update fields on an A_Customer entity in SAP S/4HANA Cloud (API_BUSINESS_PARTNER). PATCH only sends the fields you provide; existing values are preserved. A_Customer PATCH is limited to modifiable fields such as OrderIsBlockedForCustomer, DeliveryIsBlock, BillingIsBlockedForCustomer, PostingIsBlocked, and DeletionIndicator. If-Match defaults to a wildcard - for safe concurrent updates pass the ETag from a prior GET to avoid lost updates.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + customer: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Customer key to update (string, up to 10 characters)', + }, + body: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'JSON object with A_Customer fields to update (e.g., {"OrderIsBlockedForCustomer":true,"DeletionIndicator":false})', + }, + ifMatch: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'If-Match ETag for optimistic concurrency. Defaults to "*" (unconditional).', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const payload = parseJsonInput>(params.body, 'body') + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('body must be a JSON object with the fields to update') + } + return { + ...baseProxyBody(params), + service: 'API_BUSINESS_PARTNER', + path: `/A_Customer(${quoteOdataKey(params.customer)})`, + method: 'PATCH', + query: { $format: 'json' }, + body: payload, + ifMatch: params.ifMatch || '*', + } + }, + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP (204 on success)' }, + data: { + type: 'json', + description: 'Null on 204 success, or updated A_Customer entity if SAP returns one', + }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/update_product.ts b/apps/sim/tools/sap_s4hana/update_product.ts new file mode 100644 index 00000000000..d129cd72187 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/update_product.ts @@ -0,0 +1,127 @@ +import type { SapProxyResponse, UpdateProductParams } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + parseJsonInput, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateProductTool: ToolConfig = { + id: 'sap_s4hana_update_product', + name: 'SAP S/4HANA Update Product', + description: + 'Update fields on an A_Product entity in SAP S/4HANA Cloud (API_PRODUCT_SRV). PATCH only sends the fields you provide; existing values are preserved. Flat scalar header fields only — deep/multi-entity updates across navigation properties are not supported by API_PRODUCT_SRV PATCH/PUT (see SAP KBA 2833338); update child entities (plant, valuation, sales data, etc.) via their own endpoints. If-Match defaults to a wildcard (unconditional) — for safe concurrent updates pass the ETag from a prior GET.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + product: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Product key to update (string, up to 40 characters)', + }, + body: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'JSON object with A_Product fields to update (e.g., {"ProductGroup":"L001","IsMarkedForDeletion":false})', + }, + ifMatch: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'If-Match ETag for optimistic concurrency. Defaults to "*" (unconditional).', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const payload = parseJsonInput>(params.body, 'body') + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('body must be a JSON object with the fields to update') + } + return { + ...baseProxyBody(params), + service: 'API_PRODUCT_SRV', + path: `/A_Product(${quoteOdataKey(params.product)})`, + method: 'PATCH', + query: { $format: 'json' }, + body: payload, + ifMatch: params.ifMatch || '*', + } + }, + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP (204 on success)' }, + data: { + type: 'json', + description: 'Null on 204 success, or updated A_Product entity if SAP returns one', + }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/update_purchase_order.ts b/apps/sim/tools/sap_s4hana/update_purchase_order.ts new file mode 100644 index 00000000000..f41cb33e4f1 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/update_purchase_order.ts @@ -0,0 +1,127 @@ +import type { SapProxyResponse, UpdatePurchaseOrderParams } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + parseJsonInput, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const updatePurchaseOrderTool: ToolConfig = { + id: 'sap_s4hana_update_purchase_order', + name: 'SAP S/4HANA Update Purchase Order', + description: + 'Update fields on an A_PurchaseOrder entity in SAP S/4HANA Cloud (API_PURCHASEORDER_PROCESS_SRV). PATCH only sends the fields you provide; existing values are preserved. If-Match defaults to a wildcard (unconditional) — for safe concurrent updates pass the ETag from a prior GET to avoid lost updates.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + purchaseOrder: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'PurchaseOrder key to update (string, up to 10 characters)', + }, + body: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'JSON object with A_PurchaseOrder fields to update (e.g., {"PurchasingGroup":"002","PurchaseOrderDate":"/Date(1735689600000)/"})', + }, + ifMatch: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'If-Match ETag for optimistic concurrency. Defaults to "*" (unconditional).', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const payload = parseJsonInput>(params.body, 'body') + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('body must be a JSON object with the fields to update') + } + return { + ...baseProxyBody(params), + service: 'API_PURCHASEORDER_PROCESS_SRV', + path: `/A_PurchaseOrder(${quoteOdataKey(params.purchaseOrder)})`, + method: 'PATCH', + query: { $format: 'json' }, + body: payload, + ifMatch: params.ifMatch || '*', + } + }, + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP (204 on success)' }, + data: { + type: 'json', + description: 'Null on 204 success, or updated A_PurchaseOrder entity if SAP returns one', + }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/update_purchase_requisition.ts b/apps/sim/tools/sap_s4hana/update_purchase_requisition.ts new file mode 100644 index 00000000000..f81913cf073 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/update_purchase_requisition.ts @@ -0,0 +1,131 @@ +import type { SapProxyResponse, UpdatePurchaseRequisitionParams } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + parseJsonInput, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const updatePurchaseRequisitionTool: ToolConfig< + UpdatePurchaseRequisitionParams, + SapProxyResponse +> = { + id: 'sap_s4hana_update_purchase_requisition', + name: 'SAP S/4HANA Update Purchase Requisition', + description: + 'Update fields on an A_PurchaseRequisitionHeader entity in SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV; deprecated since S/4HANA 2402, successor is API_PURCHASEREQUISITION_2 OData v4). PATCH only sends the fields you provide; existing values are preserved. If-Match defaults to a wildcard - for safe concurrent updates pass the ETag from a prior GET to avoid lost updates.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + purchaseRequisition: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'PurchaseRequisition key to update (string, up to 10 characters)', + }, + body: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'JSON object with A_PurchaseRequisitionHeader fields to update (e.g., {"PurchaseRequisitionType":"NB"})', + }, + ifMatch: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'If-Match ETag for optimistic concurrency. Defaults to "*" (unconditional).', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const payload = parseJsonInput>(params.body, 'body') + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('body must be a JSON object with the fields to update') + } + return { + ...baseProxyBody(params), + service: 'API_PURCHASEREQ_PROCESS_SRV', + path: `/A_PurchaseRequisitionHeader(${quoteOdataKey(params.purchaseRequisition)})`, + method: 'PATCH', + query: { $format: 'json' }, + body: payload, + ifMatch: params.ifMatch || '*', + } + }, + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP (204 on success)' }, + data: { + type: 'json', + description: + 'Null on 204 success, or updated A_PurchaseRequisitionHeader entity if SAP returns one', + }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/update_sales_order.ts b/apps/sim/tools/sap_s4hana/update_sales_order.ts new file mode 100644 index 00000000000..8a5db8fe493 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/update_sales_order.ts @@ -0,0 +1,127 @@ +import type { SapProxyResponse, UpdateSalesOrderParams } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + parseJsonInput, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateSalesOrderTool: ToolConfig = { + id: 'sap_s4hana_update_sales_order', + name: 'SAP S/4HANA Update Sales Order', + description: + 'Update fields on an A_SalesOrder entity in SAP S/4HANA Cloud (API_SALES_ORDER_SRV). PATCH only sends the fields you provide; existing values are preserved. If-Match defaults to a wildcard (unconditional) — for safe concurrent updates pass the ETag from a prior GET to avoid lost updates.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + salesOrder: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'SalesOrder key to update (string, up to 10 characters)', + }, + body: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'JSON object with A_SalesOrder fields to update (e.g., {"PurchaseOrderByCustomer":"PO-12345","HeaderBillingBlockReason":"01"})', + }, + ifMatch: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'If-Match ETag for optimistic concurrency. Defaults to "*" (unconditional).', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const payload = parseJsonInput>(params.body, 'body') + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('body must be a JSON object with the fields to update') + } + return { + ...baseProxyBody(params), + service: 'API_SALES_ORDER_SRV', + path: `/A_SalesOrder(${quoteOdataKey(params.salesOrder)})`, + method: 'PATCH', + query: { $format: 'json' }, + body: payload, + ifMatch: params.ifMatch || '*', + } + }, + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP (204 on success)' }, + data: { + type: 'json', + description: 'Null on 204 success, or updated A_SalesOrder entity if SAP returns one', + }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/update_supplier.ts b/apps/sim/tools/sap_s4hana/update_supplier.ts new file mode 100644 index 00000000000..1dd9c13e84c --- /dev/null +++ b/apps/sim/tools/sap_s4hana/update_supplier.ts @@ -0,0 +1,127 @@ +import type { SapProxyResponse, UpdateSupplierParams } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + parseJsonInput, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateSupplierTool: ToolConfig = { + id: 'sap_s4hana_update_supplier', + name: 'SAP S/4HANA Update Supplier', + description: + 'Update fields on an A_Supplier entity in SAP S/4HANA Cloud (API_BUSINESS_PARTNER). PATCH only sends the fields you provide; existing values are preserved. A_Supplier PATCH is limited to modifiable fields such as PostingIsBlocked, PurchasingIsBlocked, PaymentIsBlockedForSupplier, DeletionIndicator, and SupplierAccountGroup. If-Match defaults to a wildcard - for safe concurrent updates pass the ETag from a prior GET to avoid lost updates.', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + supplier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Supplier key to update (string, up to 10 characters)', + }, + body: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'JSON object with A_Supplier fields to update (e.g., {"PaymentIsBlockedForSupplier":true,"PostingIsBlocked":true})', + }, + ifMatch: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'If-Match ETag for optimistic concurrency. Defaults to "*" (unconditional).', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const payload = parseJsonInput>(params.body, 'body') + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('body must be a JSON object with the fields to update') + } + return { + ...baseProxyBody(params), + service: 'API_BUSINESS_PARTNER', + path: `/A_Supplier(${quoteOdataKey(params.supplier)})`, + method: 'PATCH', + query: { $format: 'json' }, + body: payload, + ifMatch: params.ifMatch || '*', + } + }, + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP (204 on success)' }, + data: { + type: 'json', + description: 'Null on 204 success, or updated A_Supplier entity if SAP returns one', + }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/utils.ts b/apps/sim/tools/sap_s4hana/utils.ts new file mode 100644 index 00000000000..203893a5eae --- /dev/null +++ b/apps/sim/tools/sap_s4hana/utils.ts @@ -0,0 +1,90 @@ +import type { SapBaseParams } from '@/tools/sap_s4hana/types' + +export const SAP_PROXY_URL = '/api/tools/sap_s4hana/proxy' + +export function baseProxyBody(params: SapBaseParams) { + const body: Record = {} + if (params.deploymentType) body.deploymentType = params.deploymentType + if (params.authType) body.authType = params.authType + if (params.subdomain) body.subdomain = params.subdomain + if (params.region) body.region = params.region + if (params.baseUrl) body.baseUrl = params.baseUrl + if (params.tokenUrl) body.tokenUrl = params.tokenUrl + if (params.clientId) body.clientId = params.clientId + if (params.clientSecret) body.clientSecret = params.clientSecret + if (params.username) body.username = params.username + if (params.password) body.password = params.password + return body +} + +export function buildOdataQuery(opts: { + filter?: string + top?: number + skip?: number + orderBy?: string + select?: string + expand?: string +}): Record { + const query: Record = { $format: 'json' } + if (opts.filter) query.$filter = opts.filter + if (typeof opts.top === 'number') query.$top = opts.top + if (typeof opts.skip === 'number') query.$skip = opts.skip + if (opts.orderBy) query.$orderby = opts.orderBy + if (opts.select) query.$select = opts.select + if (opts.expand) query.$expand = opts.expand + return query +} + +export function buildEntityQuery(opts: { + select?: string + expand?: string +}): Record { + const query: Record = { $format: 'json' } + if (opts.select) query.$select = opts.select + if (opts.expand) query.$expand = opts.expand + return query +} + +export function parseJsonInput(input: unknown, fieldName: string): T | undefined { + if (input === undefined || input === null || input === '') { + return undefined + } + if (typeof input === 'object') return input as T + if (typeof input !== 'string') { + throw new Error(`Invalid ${fieldName}: expected JSON object or string`) + } + try { + return JSON.parse(input) as T + } catch { + throw new Error(`Invalid ${fieldName}: must be valid JSON`) + } +} + +export function quoteOdataKey(value: string): string { + return `'${String(value).trim().replace(/'/g, "''")}'` +} + +export interface SapProxyToolOutput { + status: number + data: unknown +} + +export async function transformSapProxyResponse( + response: Response +): Promise<{ success: boolean; output: SapProxyToolOutput; error?: string }> { + const data = (await response.json().catch(() => ({}))) as { + success?: boolean + output?: SapProxyToolOutput + error?: string + status?: number + } + + if (!response.ok || data.success === false) { + throw new Error(data.error || `SAP request failed: HTTP ${response.status}`) + } + + return { + success: true, + output: data.output ?? { status: response.status, data: null }, + } +} From ca814f021d259348d3247509df80587528e8773a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 27 Apr 2026 15:58:41 -0700 Subject: [PATCH 06/28] fix(ui): display file upload error messages (#4315) * fix(ui): display file upload messages * Address pr comments --- .../knowledge/hooks/use-knowledge-upload.ts | 2 +- .../settings/hooks/use-profile-picture-upload.ts | 6 ++++-- .../user-input/hooks/use-file-attachments.ts | 13 +++++++++++-- .../components/file-upload/file-upload.tsx | 3 ++- .../w/[workflowId]/hooks/use-workflow-execution.ts | 10 ++++++++-- .../sidebar/hooks/use-workspace-logo-upload.ts | 6 ++++-- 6 files changed, 30 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts index e806947a572..54e564d05a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts @@ -794,7 +794,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { } throw new DirectUploadError( - `Failed to upload ${file.name}: ${errorData?.error || 'Unknown error'}`, + `Failed to upload ${file.name}: ${errorData?.message || errorData?.error || 'Unknown error'}`, errorData ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts index ff4c582e414..f2e67be738b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts @@ -78,8 +78,10 @@ export function useProfilePictureUpload({ }) if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: response.statusText })) - throw new Error(errorData.error || `Failed to upload file: ${response.status}`) + const errorData = await response.json().catch(() => ({ message: response.statusText })) + throw new Error( + errorData.message || errorData.error || `Failed to upload file: ${response.status}` + ) } const data = await response.json() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts index af19b89aeb9..cfee82d8680 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts @@ -2,7 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { toast } from '@/components/emcn' import { resolveFileType } from '@/lib/uploads/utils/file-utils' const logger = createLogger('useFileAttachments') @@ -147,9 +149,13 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { if (!uploadResponse.ok) { const errorData = await uploadResponse.json().catch(() => ({ - error: `Upload failed: ${uploadResponse.status}`, + message: `Upload failed: ${uploadResponse.status}`, })) - throw new Error(errorData.error || `Failed to upload file: ${uploadResponse.status}`) + throw new Error( + errorData.message || + errorData.error || + `Failed to upload file: ${uploadResponse.status}` + ) } const uploadData = await uploadResponse.json() @@ -172,6 +178,9 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { ) } catch (error) { logger.error(`File upload failed: ${error}`) + toast.error(`Couldn't upload "${file.name}"`, { + description: toError(error).message, + }) if (placeholder.previewUrl) URL.revokeObjectURL(placeholder.previewUrl) setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholder.id)) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index f3147311489..37873145533 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -328,7 +328,8 @@ export function FileUpload({ const data = await response.json() if (!response.ok) { - const errorMessage = data.error || `Failed to upload file: ${response.status}` + const errorMessage = + data.message || data.error || `Failed to upload file: ${response.status}` uploadErrors.push(`${file.name}: ${errorMessage}`) setUploadError(errorMessage) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 685dad3e1e1..66054a7e4b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -494,8 +494,14 @@ export function useWorkflowExecution() { logger.error('Unexpected upload response format:', uploadResult) } } else { - const errorText = await response.text() - const message = `Failed to upload ${fileData.name}: ${response.status} ${errorText}` + const cloned = response.clone() + const errorData = await response.json().catch(() => null) + const reason = + errorData?.message || + errorData?.error || + (await cloned.text().catch(() => '')) || + `${response.status}` + const message = `Failed to upload ${fileData.name}: ${reason}` logger.error(message) if (isUploadErrorCapable(workflowInput)) { try { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts index 86d99fd1b4d..d0dddb12648 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts @@ -73,8 +73,10 @@ export function useWorkspaceLogoUpload({ }) if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: response.statusText })) - throw new Error(errorData.error || `Failed to upload file: ${response.status}`) + const errorData = await response.json().catch(() => ({ message: response.statusText })) + throw new Error( + errorData.message || errorData.error || `Failed to upload file: ${response.status}` + ) } const data = await response.json() From c95ac3bc23e12d4cf430c23c98c27349652c0769 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 27 Apr 2026 18:02:51 -0700 Subject: [PATCH 07/28] improvement(browser-use,stagehand): expose live session URLs (#4314) * improvement(browser-use,stagehand): expose live session URLs and align with latest API specs - Browser Use: switch to v2 camelCase schema, fetch live URL from sessions endpoint, add startUrl/maxSteps/allowedDomains/vision/flashMode/thinking/systemPromptExtension/structuredOutput/metadata params, surface liveUrl/shareUrl/sessionId outputs - Stagehand: fetch Browserbase debug URL, add mode/maxSteps params, surface liveViewUrl/sessionId outputs, bump @browserbasehq/stagehand to ^3.2.1, update to claude-sonnet-4-6 Co-Authored-By: Claude Opus 4.7 * fix(browser-use): respect API default for highlightElements Only send highlightElements when user explicitly toggles it; previously defaulted to true which silently overrode the v2 API default of false. Co-Authored-By: Claude Opus 4.7 * fix(browser-use,stagehand): address PR review feedback - Browser Use: fetch liveUrl during polling once sessionId is known, instead of immediately after task creation. Handles tasks started without profile_id (where sessionId isn't returned in create response) and ensures session is active before fetching. - Stagehand: coerce empty/whitespace maxSteps strings to undefined so they're dropped from the request body instead of failing zod validation as ''. Co-Authored-By: Claude Opus 4.7 * fix(stagehand): preserve liveViewUrl and sessionId on agent error If the agent throws after Browserbase session init succeeds, callers can still surface the live view / session ID for debugging. Co-Authored-By: Claude Opus 4.7 * fix(browser-use): coerce empty maxSteps strings to undefined Mirrors the Stagehand block's handling so a cleared field doesn't pass through as ''. Co-Authored-By: Claude Opus 4.7 * fix(browser-use): skip metadata when empty --------- Co-authored-by: Claude Opus 4.7 --- .../content/docs/en/tools/browser_use.mdx | 30 +- apps/docs/content/docs/en/tools/stagehand.mdx | 4 + .../app/api/tools/stagehand/agent/route.ts | 48 ++- .../app/api/tools/stagehand/extract/route.ts | 12 +- apps/sim/blocks/blocks/browser_use.ts | 140 ++++++- apps/sim/blocks/blocks/stagehand.ts | 65 ++-- apps/sim/package.json | 2 +- apps/sim/tools/browser_use/run_task.ts | 365 +++++++++++++----- apps/sim/tools/browser_use/types.ts | 39 +- apps/sim/tools/stagehand/agent.ts | 27 ++ apps/sim/tools/stagehand/types.ts | 4 + bun.lock | 14 +- 12 files changed, 570 insertions(+), 180 deletions(-) diff --git a/apps/docs/content/docs/en/tools/browser_use.mdx b/apps/docs/content/docs/en/tools/browser_use.mdx index c8e5df7ec5e..26c1bc1e503 100644 --- a/apps/docs/content/docs/en/tools/browser_use.mdx +++ b/apps/docs/content/docs/en/tools/browser_use.mdx @@ -42,9 +42,18 @@ Runs a browser automation task using BrowserUse | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `task` | string | Yes | What should the browser agent do | -| `variables` | json | No | Optional variables to use as secrets \(format: \{key: value\}\) | -| `save_browser_data` | boolean | No | Whether to save browser data | -| `model` | string | No | LLM model to use \(default: gpt-4o\) | +| `startUrl` | string | No | Initial page URL to start the agent on \(reduces navigation steps\) | +| `variables` | json | No | Optional secrets injected into the task \(format: \{key: value\}\) | +| `allowedDomains` | string | No | Comma-separated list of domains the agent is allowed to visit | +| `maxSteps` | number | No | Maximum number of steps the agent may take \(default 100, max 10000\) | +| `flashMode` | boolean | No | Enable flash mode \(faster, less careful navigation\) | +| `thinking` | boolean | No | Enable extended reasoning mode | +| `vision` | string | No | Vision capability: "true", "false", or "auto" | +| `systemPromptExtension` | string | No | Optional text appended to the agent system prompt \(max 2000 chars\) | +| `structuredOutput` | string | No | Stringified JSON schema for the structured output | +| `highlightElements` | boolean | No | Highlight interactive elements on the page \(default true\) | +| `metadata` | json | No | Custom key-value metadata \(up to 10 pairs\) for tracking | +| `model` | string | No | LLM model identifier \(e.g. browser-use-2.0\) | | `apiKey` | string | Yes | API key for BrowserUse API | | `profile_id` | string | No | Browser profile ID for persistent sessions \(cookies, login state\) | @@ -54,7 +63,18 @@ Runs a browser automation task using BrowserUse | --------- | ---- | ----------- | | `id` | string | Task execution identifier | | `success` | boolean | Task completion status | -| `output` | json | Task output data | -| `steps` | json | Execution steps taken | +| `output` | json | Final task output \(string or structured\) | +| `steps` | array | Steps the agent executed \(number, memory, nextGoal, url, actions, duration\) | +| ↳ `number` | number | Sequential step number | +| ↳ `memory` | string | Agent memory at this step | +| ↳ `evaluationPreviousGoal` | string | Evaluation of previous goal completion | +| ↳ `nextGoal` | string | Goal for the next step | +| ↳ `url` | string | Current URL of the browser | +| ↳ `screenshotUrl` | string | Optional screenshot URL | +| ↳ `actions` | array | Stringified JSON actions performed | +| ↳ `duration` | number | Step duration in seconds | +| `liveUrl` | string | Embeddable live browser session URL \(active during execution\) | +| `shareUrl` | string | Public shareable URL for the recorded session \(post-run\) | +| `sessionId` | string | Browser Use session identifier | diff --git a/apps/docs/content/docs/en/tools/stagehand.mdx b/apps/docs/content/docs/en/tools/stagehand.mdx index d03ba626a77..c83d0cf5431 100644 --- a/apps/docs/content/docs/en/tools/stagehand.mdx +++ b/apps/docs/content/docs/en/tools/stagehand.mdx @@ -72,6 +72,8 @@ Run an autonomous web agent to complete tasks and extract structured data | `provider` | string | No | AI provider to use: openai or anthropic | | `apiKey` | string | Yes | API key for the selected provider | | `outputSchema` | json | No | Optional JSON schema defining the structure of data the agent should return | +| `mode` | string | No | Agent tool mode: dom \(default\), hybrid, or cua | +| `maxSteps` | number | No | Maximum agent steps \(default 20, max 200\) | #### Output @@ -92,5 +94,7 @@ Run an autonomous web agent to complete tasks and extract structured data | ↳ `timestamp` | number | Unix timestamp when the action was performed | | ↳ `timeMs` | number | Time in milliseconds \(for wait actions\) | | `structuredOutput` | object | Extracted data matching the provided output schema | +| `liveViewUrl` | string | Embeddable Browserbase live view URL \(active only while the session is running\) | +| `sessionId` | string | Browserbase session identifier | diff --git a/apps/sim/app/api/tools/stagehand/agent/route.ts b/apps/sim/app/api/tools/stagehand/agent/route.ts index afc32d5bc6a..3c17d60eeb4 100644 --- a/apps/sim/app/api/tools/stagehand/agent/route.ts +++ b/apps/sim/app/api/tools/stagehand/agent/route.ts @@ -22,6 +22,8 @@ const requestSchema = z.object({ variables: z.any(), provider: z.enum(['openai', 'anthropic']).optional().default('openai'), apiKey: z.string(), + mode: z.enum(['dom', 'hybrid', 'cua']).optional().default('dom'), + maxSteps: z.number().int().min(1).max(200).optional().default(20), }) /** @@ -121,7 +123,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const params = validationResult.data - const { task, startUrl: rawStartUrl, outputSchema, provider, apiKey } = params + const { task, startUrl: rawStartUrl, outputSchema, provider, apiKey, mode, maxSteps } = params const variablesObject = processVariables(params.variables) const startUrl = normalizeUrl(rawStartUrl) @@ -165,8 +167,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Invalid Anthropic API key format' }, { status: 400 }) } - const modelName = - provider === 'anthropic' ? 'anthropic/claude-sonnet-4-5-20250929' : 'openai/gpt-5' + const modelName = provider === 'anthropic' ? 'anthropic/claude-sonnet-4-6' : 'openai/gpt-5' + + let sessionId: string | null = null + let liveViewUrl: string | null = null try { logger.info('Initializing Stagehand with Browserbase (v3)', { provider, modelName }) @@ -190,6 +194,35 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await stagehand.init() logger.info('Stagehand initialized successfully') + sessionId = stagehand.browserbaseSessionID ?? null + if (sessionId) { + try { + const debugResponse = await fetch( + `https://api.browserbase.com/v1/sessions/${sessionId}/debug`, + { + method: 'GET', + headers: { + 'X-BB-API-Key': BROWSERBASE_API_KEY, + }, + } + ) + if (debugResponse.ok) { + const debugData = (await debugResponse.json()) as { + debuggerFullscreenUrl?: string + debuggerUrl?: string + } + liveViewUrl = debugData.debuggerFullscreenUrl ?? debugData.debuggerUrl ?? null + if (liveViewUrl) { + logger.info(`Browserbase live view URL: ${liveViewUrl}`) + } + } else { + logger.warn(`Failed to fetch Browserbase debug URL: ${debugResponse.statusText}`) + } + } catch (debugError) { + logger.warn('Error fetching Browserbase debug URL', { error: debugError }) + } + } + const page = stagehand.context.pages()[0] logger.info(`Navigating to ${startUrl}`) await page.goto(startUrl, { waitUntil: 'networkidle' }) @@ -223,13 +256,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { apiKey: apiKey, }, systemPrompt: agentInstructions, + mode, }) - logger.info('Executing agent task', { task: taskWithVariables }) + logger.info('Executing agent task', { task: taskWithVariables, mode, maxSteps }) const agentExecutionResult = await agent.execute({ instruction: taskWithVariables, - maxSteps: 20, + maxSteps, }) const agentResult = { @@ -293,6 +327,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ agentResult, structuredOutput, + liveViewUrl, + sessionId, }) } catch (error) { logger.error('Stagehand agent execution error', { @@ -327,6 +363,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { error: errorMessage, details: errorDetails, + liveViewUrl, + sessionId, }, { status: 500 } ) diff --git a/apps/sim/app/api/tools/stagehand/extract/route.ts b/apps/sim/app/api/tools/stagehand/extract/route.ts index c39f5c78534..1ec99a182d9 100644 --- a/apps/sim/app/api/tools/stagehand/extract/route.ts +++ b/apps/sim/app/api/tools/stagehand/extract/route.ts @@ -17,8 +17,6 @@ const BROWSERBASE_PROJECT_ID = env.BROWSERBASE_PROJECT_ID const requestSchema = z.object({ instruction: z.string(), schema: z.record(z.any()), - useTextExtract: z.boolean().optional().default(false), - selector: z.string().nullable().optional(), provider: z.enum(['openai', 'anthropic']).optional().default('openai'), apiKey: z.string(), url: z.string().url(), @@ -51,7 +49,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const params = validationResult.data - const { url: rawUrl, instruction, selector, provider, apiKey, schema } = params + const { url: rawUrl, instruction, provider, apiKey, schema } = params const url = normalizeUrl(rawUrl) const urlValidation = await validateUrlWithDNS(url, 'url') if (!urlValidation.isValid) { @@ -101,8 +99,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - const modelName = - provider === 'anthropic' ? 'anthropic/claude-sonnet-4-5-20250929' : 'openai/gpt-5' + const modelName = provider === 'anthropic' ? 'anthropic/claude-sonnet-4-6' : 'openai/gpt-5' logger.info('Initializing Stagehand with Browserbase (v3)', { provider, modelName }) @@ -162,14 +159,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info('Calling stagehand.extract with options', { hasInstruction: !!instruction, hasSchema: !!zodSchema, - hasSelector: !!selector, }) let extractedData if (zodSchema) { - extractedData = await stagehand.extract(instruction, zodSchema, { - selector: selector || undefined, - }) + extractedData = await stagehand.extract(instruction, zodSchema) } else { extractedData = await stagehand.extract(instruction) } diff --git a/apps/sim/blocks/blocks/browser_use.ts b/apps/sim/blocks/blocks/browser_use.ts index 1e059cf25c1..1d8c8c28999 100644 --- a/apps/sim/blocks/blocks/browser_use.ts +++ b/apps/sim/blocks/blocks/browser_use.ts @@ -23,6 +23,12 @@ export const BrowserUseBlock: BlockConfig = { placeholder: 'Describe what the browser agent should do...', required: true, }, + { + id: 'startUrl', + title: 'Start URL', + type: 'short-input', + placeholder: 'https://example.com (optional starting URL)', + }, { id: 'variables', title: 'Variables (Secrets)', @@ -51,22 +57,85 @@ export const BrowserUseBlock: BlockConfig = { { label: 'Claude 3.7 Sonnet', id: 'claude-3-7-sonnet-20250219' }, { label: 'Claude Sonnet 4', id: 'claude-sonnet-4-20250514' }, { label: 'Claude Sonnet 4.5', id: 'claude-sonnet-4-5-20250929' }, + { label: 'Claude Sonnet 4.6', id: 'claude-sonnet-4-6' }, { label: 'Claude Opus 4.5', id: 'claude-opus-4-5-20251101' }, { label: 'Llama 4 Maverick', id: 'llama-4-maverick-17b-128e-instruct' }, ], }, - { - id: 'save_browser_data', - title: 'Save Browser Data', - type: 'switch', - placeholder: 'Save browser data', - }, { id: 'profile_id', title: 'Profile ID', type: 'short-input', placeholder: 'Enter browser profile ID (optional)', }, + { + id: 'maxSteps', + title: 'Max Steps', + type: 'short-input', + placeholder: '100', + mode: 'advanced', + }, + { + id: 'allowedDomains', + title: 'Allowed Domains', + type: 'short-input', + placeholder: 'example.com, docs.example.com', + mode: 'advanced', + }, + { + id: 'vision', + title: 'Vision', + type: 'dropdown', + options: [ + { label: 'Auto (default)', id: 'auto' }, + { label: 'Enabled', id: 'true' }, + { label: 'Disabled', id: 'false' }, + ], + mode: 'advanced', + }, + { + id: 'flashMode', + title: 'Flash Mode', + type: 'switch', + placeholder: 'Faster but less careful navigation', + mode: 'advanced', + }, + { + id: 'thinking', + title: 'Thinking', + type: 'switch', + placeholder: 'Enable extended reasoning', + mode: 'advanced', + }, + { + id: 'highlightElements', + title: 'Highlight Elements', + type: 'switch', + placeholder: 'Visually mark interactive elements', + mode: 'advanced', + }, + { + id: 'systemPromptExtension', + title: 'System Prompt Extension', + type: 'long-input', + placeholder: 'Append custom instructions to the agent system prompt (max 2000 chars)', + mode: 'advanced', + }, + { + id: 'structuredOutput', + title: 'Structured Output Schema', + type: 'code', + language: 'json', + placeholder: 'Stringified JSON schema for structured output', + mode: 'advanced', + }, + { + id: 'metadata', + title: 'Metadata', + type: 'table', + columns: ['Key', 'Value'], + mode: 'advanced', + }, { id: 'apiKey', title: 'API Key', @@ -78,19 +147,68 @@ export const BrowserUseBlock: BlockConfig = { ], tools: { access: ['browser_use_run_task'], + config: { + tool: () => 'browser_use_run_task', + params: (params) => { + const next: Record = { ...params } + if (typeof next.maxSteps === 'string') { + const trimmed = next.maxSteps.trim() + if (trimmed === '') { + next.maxSteps = undefined + } else { + const n = Number(trimmed) + next.maxSteps = Number.isFinite(n) ? n : undefined + } + } + if (next.vision === 'true') next.vision = true + else if (next.vision === 'false') next.vision = false + if (next.metadata && Array.isArray(next.metadata)) { + const obj: Record = {} + for (const row of next.metadata as Array>) { + const key = row?.cells?.Key ?? row?.Key + const value = row?.cells?.Value ?? row?.Value + if (key) obj[key] = String(value ?? '') + } + next.metadata = obj + } + return next + }, + }, }, inputs: { task: { type: 'string', description: 'Browser automation task' }, + startUrl: { type: 'string', description: 'Starting URL for the agent' }, apiKey: { type: 'string', description: 'BrowserUse API key' }, - variables: { type: 'json', description: 'Task variables' }, - model: { type: 'string', description: 'AI model to use' }, - save_browser_data: { type: 'boolean', description: 'Save browser data' }, + variables: { type: 'json', description: 'Secrets to inject into the task' }, + model: { type: 'string', description: 'LLM model to use' }, profile_id: { type: 'string', description: 'Browser profile ID for persistent sessions' }, + maxSteps: { type: 'number', description: 'Maximum agent steps' }, + allowedDomains: { type: 'string', description: 'Comma-separated allowed domains' }, + vision: { type: 'string', description: 'Vision capability (auto / true / false)' }, + flashMode: { type: 'boolean', description: 'Enable flash mode' }, + thinking: { type: 'boolean', description: 'Enable extended reasoning' }, + highlightElements: { type: 'boolean', description: 'Highlight interactive elements' }, + systemPromptExtension: { type: 'string', description: 'Custom system prompt extension' }, + structuredOutput: { type: 'string', description: 'Stringified JSON schema' }, + metadata: { type: 'json', description: 'Custom key-value metadata' }, }, outputs: { id: { type: 'string', description: 'Task execution identifier' }, success: { type: 'boolean', description: 'Task completion status' }, - output: { type: 'json', description: 'Task output data' }, - steps: { type: 'json', description: 'Execution steps taken' }, + output: { type: 'json', description: 'Final task output (string or structured)' }, + steps: { + type: 'json', + description: + 'Steps the agent executed (number, memory, evaluationPreviousGoal, nextGoal, url, screenshotUrl, actions, duration)', + }, + liveUrl: { + type: 'string', + description: 'Embeddable live browser session URL (active during execution)', + }, + shareUrl: { + type: 'string', + description: 'Public shareable URL for the session (post-run)', + }, + sessionId: { type: 'string', description: 'Browser Use session identifier' }, }, } diff --git a/apps/sim/blocks/blocks/stagehand.ts b/apps/sim/blocks/blocks/stagehand.ts index 385328c1944..6c7b0c11e3a 100644 --- a/apps/sim/blocks/blocks/stagehand.ts +++ b/apps/sim/blocks/blocks/stagehand.ts @@ -1,28 +1,6 @@ import { StagehandIcon } from '@/components/icons' import { AuthMode, type BlockConfig, IntegrationType } from '@/blocks/types' -import type { ToolResponse } from '@/tools/types' - -export interface StagehandExtractResponse extends ToolResponse { - output: { - data: Record - } -} - -export interface StagehandAgentResponse extends ToolResponse { - output: { - agentResult: { - success: boolean - completed: boolean - message: string - actions?: Array<{ - type: string - description: string - result?: string - }> - } - structuredOutput?: Record - } -} +import type { StagehandAgentResponse, StagehandExtractResponse } from '@/tools/stagehand/types' export type StagehandResponse = StagehandExtractResponse | StagehandAgentResponse @@ -345,6 +323,27 @@ Example 3 (Data Collection): generationType: 'json-schema', }, }, + { + id: 'mode', + title: 'Agent Mode', + type: 'dropdown', + options: [ + { label: 'DOM (default)', id: 'dom' }, + { label: 'Hybrid', id: 'hybrid' }, + { label: 'CUA', id: 'cua' }, + ], + value: () => 'dom', + condition: { field: 'operation', value: 'agent' }, + mode: 'advanced', + }, + { + id: 'maxSteps', + title: 'Max Steps', + type: 'short-input', + placeholder: '20', + condition: { field: 'operation', value: 'agent' }, + mode: 'advanced', + }, // Shared API key field { id: 'apiKey', @@ -361,6 +360,19 @@ Example 3 (Data Collection): tool: (params) => { return params.operation === 'agent' ? 'stagehand_agent' : 'stagehand_extract' }, + params: (params) => { + const next: Record = { ...params } + if (typeof next.maxSteps === 'string') { + const trimmed = next.maxSteps.trim() + if (trimmed === '') { + next.maxSteps = undefined + } else { + const n = Number(trimmed) + next.maxSteps = Number.isFinite(n) ? n : undefined + } + } + return next + }, }, }, inputs: { @@ -376,6 +388,8 @@ Example 3 (Data Collection): task: { type: 'string', description: 'Task description (agent operation)' }, variables: { type: 'json', description: 'Task variables (agent operation)' }, outputSchema: { type: 'json', description: 'Output schema (agent operation)' }, + mode: { type: 'string', description: 'Agent mode: dom, hybrid, or cua (agent operation)' }, + maxSteps: { type: 'number', description: 'Max agent steps (agent operation)' }, }, outputs: { // Extract outputs @@ -383,5 +397,10 @@ Example 3 (Data Collection): // Agent outputs agentResult: { type: 'json', description: 'Agent execution result (agent operation)' }, structuredOutput: { type: 'json', description: 'Structured output data (agent operation)' }, + liveViewUrl: { + type: 'string', + description: 'Embeddable Browserbase live view URL (agent operation)', + }, + sessionId: { type: 'string', description: 'Browserbase session identifier (agent operation)' }, }, } diff --git a/apps/sim/package.json b/apps/sim/package.json index 1322e2302c2..d9430284dc6 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -55,7 +55,7 @@ "@azure/storage-blob": "12.27.0", "@better-auth/sso": "1.3.12", "@better-auth/stripe": "1.3.12", - "@browserbasehq/stagehand": "^3.0.5", + "@browserbasehq/stagehand": "^3.2.1", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@e2b/code-interpreter": "^2.0.0", "@google/genai": "1.34.0", diff --git a/apps/sim/tools/browser_use/run_task.ts b/apps/sim/tools/browser_use/run_task.ts index e87f1f965f2..f4ea48859f9 100644 --- a/apps/sim/tools/browser_use/run_task.ts +++ b/apps/sim/tools/browser_use/run_task.ts @@ -9,13 +9,14 @@ const logger = createLogger('BrowserUseTool') const POLL_INTERVAL_MS = 5000 const MAX_POLL_TIME_MS = getMaxExecutionTimeout() const MAX_CONSECUTIVE_ERRORS = 3 +const API_BASE = 'https://api.browser-use.com/api/v2' async function createSessionWithProfile( profileId: string, apiKey: string ): Promise<{ sessionId: string } | { error: string }> { try { - const response = await fetch('https://api.browser-use.com/api/v2/sessions', { + const response = await fetch(`${API_BASE}/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -43,7 +44,7 @@ async function createSessionWithProfile( async function stopSession(sessionId: string, apiKey: string): Promise { try { - const response = await fetch(`https://api.browser-use.com/api/v2/sessions/${sessionId}`, { + const response = await fetch(`${API_BASE}/sessions/${sessionId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', @@ -62,58 +63,92 @@ async function stopSession(sessionId: string, apiKey: string): Promise { } } -function buildRequestBody( - params: BrowserUseRunTaskParams, - sessionId?: string -): Record { - const requestBody: Record = { - task: params.task, +async function fetchSessionLiveUrl( + sessionId: string, + apiKey: string +): Promise<{ liveUrl: string | null; publicShareUrl: string | null }> { + try { + const response = await fetch(`${API_BASE}/sessions/${sessionId}`, { + method: 'GET', + headers: { 'X-Browser-Use-API-Key': apiKey }, + }) + if (!response.ok) { + return { liveUrl: null, publicShareUrl: null } + } + const data = (await response.json()) as { liveUrl?: string; publicShareUrl?: string } + return { + liveUrl: data.liveUrl ?? null, + publicShareUrl: data.publicShareUrl ?? null, + } + } catch (error: any) { + logger.warn(`Error fetching session ${sessionId}:`, error) + return { liveUrl: null, publicShareUrl: null } } +} - if (sessionId) { - requestBody.sessionId = sessionId - logger.info(`Using session ${sessionId} for task`) - } +function normalizeSecrets(variables: BrowserUseRunTaskParams['variables']): Record { + const secrets: Record = {} + if (!variables) return secrets - if (params.variables) { - let secrets: Record = {} - - if (Array.isArray(params.variables)) { - logger.info('Converting variables array to dictionary format') - params.variables.forEach((row: any) => { - if (row.cells?.Key && row.cells.Value !== undefined) { - secrets[row.cells.Key] = row.cells.Value - logger.info(`Added secret for key: ${row.cells.Key}`) - } else if (row.Key && row.Value !== undefined) { - secrets[row.Key] = row.Value - logger.info(`Added secret for key: ${row.Key}`) - } - }) - } else if (typeof params.variables === 'object' && params.variables !== null) { - logger.info('Using variables object directly') - secrets = params.variables + if (Array.isArray(variables)) { + for (const row of variables as Array>) { + if (row?.cells?.Key && row.cells.Value !== undefined) { + secrets[row.cells.Key] = row.cells.Value + } else if (row?.Key && row.Value !== undefined) { + secrets[row.Key] = row.Value + } } - - if (Object.keys(secrets).length > 0) { - logger.info(`Found ${Object.keys(secrets).length} secrets to include`) - requestBody.secrets = secrets - } else { - logger.warn('No usable secrets found in variables') + } else if (typeof variables === 'object') { + for (const [k, v] of Object.entries(variables)) { + if (typeof v === 'string') secrets[k] = v } } + return secrets +} - if (params.model) { - requestBody.llm_model = params.model - } - - if (params.save_browser_data) { - requestBody.save_browser_data = params.save_browser_data - } +function parseAllowedDomains(input?: string | string[]): string[] | undefined { + if (!input) return undefined + const arr = Array.isArray(input) + ? input + : input + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + return arr.length > 0 ? arr : undefined +} - requestBody.use_adblock = true - requestBody.highlight_elements = true +function buildRequestBody( + params: BrowserUseRunTaskParams, + sessionId?: string +): Record { + const body: Record = { task: params.task } + + if (sessionId) body.sessionId = sessionId + if (params.model) body.llm = params.model + if (params.startUrl?.trim()) body.startUrl = params.startUrl.trim() + if (typeof params.maxSteps === 'number' && params.maxSteps > 0) body.maxSteps = params.maxSteps + if (params.structuredOutput) body.structuredOutput = params.structuredOutput + if (typeof params.flashMode === 'boolean') body.flashMode = params.flashMode + if (typeof params.thinking === 'boolean') body.thinking = params.thinking + if (typeof params.vision === 'boolean' || params.vision === 'auto') body.vision = params.vision + if (params.systemPromptExtension) body.systemPromptExtension = params.systemPromptExtension + if (typeof params.highlightElements === 'boolean') + body.highlightElements = params.highlightElements + + const allowedDomains = parseAllowedDomains(params.allowedDomains) + if (allowedDomains) body.allowedDomains = allowedDomains + + const secrets = normalizeSecrets(params.variables) + if (Object.keys(secrets).length > 0) body.secrets = secrets + + if ( + params.metadata && + typeof params.metadata === 'object' && + Object.keys(params.metadata).length > 0 + ) + body.metadata = params.metadata - return requestBody + return body } async function fetchTaskStatus( @@ -121,30 +156,36 @@ async function fetchTaskStatus( apiKey: string ): Promise<{ ok: true; data: any } | { ok: false; error: string }> { try { - const response = await fetch(`https://api.browser-use.com/api/v2/tasks/${taskId}`, { + const response = await fetch(`${API_BASE}/tasks/${taskId}`, { method: 'GET', - headers: { - 'X-Browser-Use-API-Key': apiKey, - }, + headers: { 'X-Browser-Use-API-Key': apiKey }, }) if (!response.ok) { return { ok: false, error: `HTTP ${response.status}: ${response.statusText}` } } - const data = await response.json() - return { ok: true, data } + return { ok: true, data: await response.json() } } catch (error: any) { return { ok: false, error: error.message || 'Network error' } } } -async function pollForCompletion( - taskId: string, - apiKey: string -): Promise<{ success: boolean; output: any; steps: any[]; error?: string }> { - let liveUrlLogged = false +interface PollResult { + success: boolean + output: any + steps: any[] + sessionId: string | null + liveUrl: string | null + publicShareUrl: string | null + error?: string +} + +async function pollForCompletion(taskId: string, apiKey: string): Promise { let consecutiveErrors = 0 + let sessionId: string | null = null + let liveUrl: string | null = null + let publicShareUrl: string | null = null const startTime = Date.now() while (Date.now() - startTime < MAX_POLL_TIME_MS) { @@ -157,11 +198,13 @@ async function pollForCompletion( ) if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { - logger.error(`Max consecutive errors reached for task ${taskId}`) return { success: false, output: null, steps: [], + sessionId, + liveUrl, + publicShareUrl, error: `Failed to poll task status after ${MAX_CONSECUTIVE_ERRORS} attempts: ${result.error}`, } } @@ -172,23 +215,31 @@ async function pollForCompletion( consecutiveErrors = 0 const taskData = result.data + if (taskData.sessionId) sessionId = taskData.sessionId const status = taskData.status logger.info(`BrowserUse task ${taskId} status: ${status}`) + if (sessionId && !liveUrl) { + const session = await fetchSessionLiveUrl(sessionId, apiKey) + if (session.liveUrl) { + liveUrl = session.liveUrl + logger.info(`BrowserUse live URL: ${liveUrl}`) + } + if (session.publicShareUrl) publicShareUrl = session.publicShareUrl + } + if (['finished', 'failed', 'stopped'].includes(status)) { return { success: status === 'finished', output: taskData.output ?? null, steps: taskData.steps || [], + sessionId, + liveUrl, + publicShareUrl, } } - if (!liveUrlLogged && taskData.live_url) { - logger.info(`BrowserUse task ${taskId} live URL: ${taskData.live_url}`) - liveUrlLogged = true - } - await sleep(POLL_INTERVAL_MS) } @@ -198,20 +249,58 @@ async function pollForCompletion( success: finalResult.data.status === 'finished', output: finalResult.data.output ?? null, steps: finalResult.data.steps || [], + sessionId: finalResult.data.sessionId ?? sessionId, + liveUrl, + publicShareUrl, } } - logger.warn( - `Task ${taskId} did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)` - ) return { success: false, output: null, steps: [], + sessionId, + liveUrl, + publicShareUrl, error: `Task did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`, } } +async function createShareUrl(sessionId: string, apiKey: string): Promise { + try { + const response = await fetch(`${API_BASE}/sessions/${sessionId}/public-share`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Browser-Use-API-Key': apiKey, + }, + }) + + if (!response.ok) { + logger.warn(`Failed to create share URL for session ${sessionId}: ${response.statusText}`) + return null + } + + const data = (await response.json()) as { shareUrl?: string; shareToken?: string } + return data.shareUrl ?? null + } catch (error: any) { + logger.warn(`Error creating share URL for session ${sessionId}:`, error) + return null + } +} + +function emptyOutput(): BrowserUseRunTaskResponse['output'] { + return { + id: '', + success: false, + output: null, + steps: [], + liveUrl: null, + shareUrl: null, + sessionId: null, + } +} + export const runTaskTool: ToolConfig = { id: 'browser_use_run_task', name: 'Browser Use', @@ -225,23 +314,77 @@ export const runTaskTool: ToolConfig ({ 'Content-Type': 'application/json', @@ -273,16 +416,7 @@ export const runTaskTool: ToolConfig + variables?: Record | Array> model?: string - save_browser_data?: boolean + startUrl?: string + allowedDomains?: string | string[] + maxSteps?: number + flashMode?: boolean + thinking?: boolean + vision?: boolean | 'auto' + systemPromptExtension?: string + structuredOutput?: string + highlightElements?: boolean + metadata?: Record profile_id?: string } export interface BrowserUseTaskStep { - id: string - step: number - evaluation_previous_goal: string - next_goal: string - url?: string - extracted_data?: Record + number: number + memory: string + evaluationPreviousGoal: string + nextGoal: string + url: string + screenshotUrl?: string | null + actions: string[] + duration?: number | null } export interface BrowserUseTaskOutput { id: string success: boolean - output: any + output: string | null steps: BrowserUseTaskStep[] + liveUrl: string | null + shareUrl: string | null + sessionId: string | null } export interface BrowserUseRunTaskResponse extends ToolResponse { @@ -30,10 +44,5 @@ export interface BrowserUseRunTaskResponse extends ToolResponse { } export interface BrowserUseResponse extends ToolResponse { - output: { - id: string - success: boolean - output: any - steps: BrowserUseTaskStep[] - } + output: BrowserUseTaskOutput } diff --git a/apps/sim/tools/stagehand/agent.ts b/apps/sim/tools/stagehand/agent.ts index f3d055a8ea4..042600f89b6 100644 --- a/apps/sim/tools/stagehand/agent.ts +++ b/apps/sim/tools/stagehand/agent.ts @@ -49,6 +49,18 @@ export const agentTool: ToolConfig visibility: 'user-only', description: 'Optional JSON schema defining the structure of data the agent should return', }, + mode: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Agent tool mode: dom (default), hybrid, or cua', + }, + maxSteps: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Maximum agent steps (default 20, max 200)', + }, }, request: { @@ -71,6 +83,8 @@ export const agentTool: ToolConfig variables: params.variables, provider: params.provider || 'openai', apiKey: params.apiKey, + mode: params.mode, + maxSteps: params.maxSteps, } }, }, @@ -82,6 +96,8 @@ export const agentTool: ToolConfig output: { agentResult: data.agentResult, structuredOutput: data.structuredOutput || {}, + liveViewUrl: data.liveViewUrl ?? null, + sessionId: data.sessionId ?? null, }, } }, @@ -96,5 +112,16 @@ export const agentTool: ToolConfig type: 'object', description: 'Extracted data matching the provided output schema', }, + liveViewUrl: { + type: 'string', + description: + 'Embeddable Browserbase live view URL (active only while the session is running)', + optional: true, + }, + sessionId: { + type: 'string', + description: 'Browserbase session identifier', + optional: true, + }, }, } diff --git a/apps/sim/tools/stagehand/types.ts b/apps/sim/tools/stagehand/types.ts index e301254bce9..9fc6bf69edf 100644 --- a/apps/sim/tools/stagehand/types.ts +++ b/apps/sim/tools/stagehand/types.ts @@ -247,6 +247,8 @@ export interface StagehandAgentParams { variables?: Record provider?: 'openai' | 'anthropic' apiKey: string + mode?: 'dom' | 'hybrid' | 'cua' + maxSteps?: number options?: { useTextExtract?: boolean selector?: string @@ -286,6 +288,8 @@ export interface StagehandAgentResponse extends ToolResponse { output: { agentResult: StagehandAgentResult structuredOutput?: Record + liveViewUrl?: string | null + sessionId?: string | null } } diff --git a/bun.lock b/bun.lock index 916bc86fb35..daa75296d82 100644 --- a/bun.lock +++ b/bun.lock @@ -109,7 +109,7 @@ "@azure/storage-blob": "12.27.0", "@better-auth/sso": "1.3.12", "@better-auth/stripe": "1.3.12", - "@browserbasehq/stagehand": "^3.0.5", + "@browserbasehq/stagehand": "^3.2.1", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@e2b/code-interpreter": "^2.0.0", "@google/genai": "1.34.0", @@ -777,7 +777,7 @@ "@browserbasehq/sdk": ["@browserbasehq/sdk@2.9.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-Xzm1+6suzQypXjley4Phqer++pjnYyST6S7CArUn3kWyGA8aruXjAV5wkmqE21lgXo9K3/OQJvCu48bKEZFNDQ=="], - "@browserbasehq/stagehand": ["@browserbasehq/stagehand@3.2.0", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@anthropic-ai/sdk": "0.39.0", "@browserbasehq/sdk": "^2.7.0", "@google/genai": "^1.22.0", "@langchain/openai": "^0.4.4", "@modelcontextprotocol/sdk": "^1.17.2", "ai": "^5.0.133", "devtools-protocol": "^0.0.1464554", "fetch-cookie": "^3.1.0", "openai": "^4.87.1", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "uuid": "^11.1.0", "ws": "^8.18.0", "zod-to-json-schema": "^3.25.0" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.73", "@ai-sdk/anthropic": "^2.0.34", "@ai-sdk/azure": "^2.0.54", "@ai-sdk/cerebras": "^1.0.25", "@ai-sdk/deepseek": "^1.0.23", "@ai-sdk/google": "^2.0.53", "@ai-sdk/google-vertex": "^3.0.70", "@ai-sdk/groq": "^2.0.24", "@ai-sdk/mistral": "^2.0.19", "@ai-sdk/openai": "^2.0.53", "@ai-sdk/perplexity": "^2.0.13", "@ai-sdk/togetherai": "^1.0.23", "@ai-sdk/xai": "^2.0.26", "@langchain/core": "^0.3.80", "bufferutil": "^4.0.9", "chrome-launcher": "^1.2.0", "ollama-ai-provider-v2": "^1.5.0", "patchright-core": "^1.55.2", "playwright": "^1.52.0", "playwright-core": "^1.54.1", "puppeteer-core": "^22.8.0" }, "peerDependencies": { "deepmerge": "^4.3.1", "zod": "^3.25.76 || ^4.2.0" } }, "sha512-X9s3sZuTL3zf8gt1o9yr4mvT2JmDRigkmBinlKF6LD+rlAIOh+nH6Cmz6xfRjZ4RgTfR0wRoE1iUTKa39YtWfA=="], + "@browserbasehq/stagehand": ["@browserbasehq/stagehand@3.2.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@anthropic-ai/sdk": "0.39.0", "@browserbasehq/sdk": "^2.7.0", "@google/genai": "^1.22.0", "@langchain/openai": "^0.4.4", "@modelcontextprotocol/sdk": "^1.17.2", "ai": "^5.0.133", "devtools-protocol": "^0.0.1464554", "fetch-cookie": "^3.1.0", "openai": "^4.87.1", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "uuid": "^11.1.0", "ws": "^8.18.0", "zod-to-json-schema": "^3.25.0" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.73", "@ai-sdk/anthropic": "^2.0.34", "@ai-sdk/azure": "^2.0.54", "@ai-sdk/cerebras": "^1.0.25", "@ai-sdk/deepseek": "^1.0.23", "@ai-sdk/google": "^2.0.53", "@ai-sdk/google-vertex": "^3.0.70", "@ai-sdk/groq": "^2.0.24", "@ai-sdk/mistral": "^2.0.19", "@ai-sdk/openai": "^2.0.53", "@ai-sdk/perplexity": "^2.0.13", "@ai-sdk/togetherai": "^1.0.23", "@ai-sdk/xai": "^2.0.26", "@langchain/core": "^0.3.80", "bufferutil": "^4.0.9", "chrome-launcher": "^1.2.0", "ollama-ai-provider-v2": "^1.5.0", "patchright-core": "^1.55.2", "playwright": "^1.52.0", "playwright-core": "^1.54.1", "puppeteer-core": "^22.8.0" }, "peerDependencies": { "deepmerge": "^4.3.1", "zod": "^3.25.76 || ^4.2.0" } }, "sha512-h7KAAaNK7JUMw97w7sj0CBsBVtjLXyEorbUoYmCwLYYWrL2IUd9WFS7gRFspCp0ww2hpVPJEKMxHumwFCPEC8g=="], "@bufbuild/protobuf": ["@bufbuild/protobuf@2.11.0", "", {}, "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ=="], @@ -2533,8 +2533,6 @@ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], @@ -3989,8 +3987,6 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "urlpattern-polyfill": ["urlpattern-polyfill@10.0.0", "", {}, "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -4971,8 +4967,6 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="], - "@browserbasehq/stagehand/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.20.2", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg=="], - "@cerebras/cerebras_cloud_sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@cerebras/cerebras_cloud_sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -6065,8 +6059,6 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], - "@browserbasehq/stagehand/@modelcontextprotocol/sdk/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], - "@cerebras/cerebras_cloud_sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -6785,8 +6777,6 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], - "@browserbasehq/stagehand/@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], From 154b9d08837cc9cf4f015596bed19006467a9d8e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 27 Apr 2026 19:27:50 -0700 Subject: [PATCH 08/28] fix(vm): categorize user or server side errors (#4283) * fix(vm): categorize user or server side errors * recategorize function syntax errors as 4xx --- .../app/api/function/execute/route.test.ts | 12 +++--- apps/sim/app/api/function/execute/route.ts | 10 +++-- .../[id]/_preview/create-preview-route.ts | 10 ++++- .../[id]/docx/preview/route.test.ts | 40 +++++++++++++++++-- .../workspaces/[id]/pdf/preview/route.test.ts | 40 +++++++++++++++++-- .../[id]/pptx/preview/route.test.ts | 40 +++++++++++++++++-- apps/sim/executor/orchestrators/loop.ts | 5 ++- apps/sim/lib/execution/isolated-vm.ts | 24 ++++++++++- apps/sim/lib/execution/sandbox/run-task.ts | 38 +++++++++++++++--- 9 files changed, 192 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 1176523c1c5..8b53c5eb057 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -191,7 +191,7 @@ describe('Function Execute API Route', () => { const response = await POST(req) const data = await response.json() - if (response.status === 500) { + if (response.status === 422 || response.status === 500) { expect(data.success).toBe(false) } else { const result = data.output?.result @@ -504,7 +504,7 @@ describe('Function Execute API Route', () => { const response = await POST(req) const data = await response.json() - expect(response.status).toBe(500) + expect(response.status).toBe(422) expect(data.success).toBe(false) expect(data.error).toBeTruthy() }) @@ -518,7 +518,7 @@ describe('Function Execute API Route', () => { const response = await POST(req) const data = await response.json() - expect(response.status).toBe(500) + expect(response.status).toBe(422) expect(data.success).toBe(false) expect(data.error).toContain('Type Error') expect(data.error).toContain('Cannot read properties of null') @@ -533,7 +533,7 @@ describe('Function Execute API Route', () => { const response = await POST(req) const data = await response.json() - expect(response.status).toBe(500) + expect(response.status).toBe(422) expect(data.success).toBe(false) expect(data.error).toContain('Reference Error') expect(data.error).toContain('undefinedVariable is not defined') @@ -548,7 +548,7 @@ describe('Function Execute API Route', () => { const response = await POST(req) const data = await response.json() - expect(response.status).toBe(500) + expect(response.status).toBe(422) expect(data.success).toBe(false) expect(data.error).toContain('Custom error message') }) @@ -562,7 +562,7 @@ describe('Function Execute API Route', () => { const response = await POST(req) const data = await response.json() - expect(response.status).toBe(500) + expect(response.status).toBe(422) expect(data.success).toBe(false) expect(data.error).toBeTruthy() }) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 63dfbff136b..680a1d158c0 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1088,9 +1088,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const executionTime = Date.now() - startTime if (isolatedResult.error) { - logger.error(`[${requestId}] Function execution failed in isolated-vm`, { + const isSystemError = isolatedResult.error.isSystemError === true + const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger) + logFn(`[${requestId}] Function execution failed in isolated-vm`, { error: isolatedResult.error, executionTime, + isSystemError, }) const ivmError = isolatedResult.error @@ -1119,7 +1122,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { resolvedCode ) - logger.error(`[${requestId}] Enhanced error details`, { + const detailLogFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger) + detailLogFn(`[${requestId}] Enhanced error details`, { originalMessage: ivmError.message, enhancedMessage: userFriendlyErrorMessage, line: enhancedError.line, @@ -1145,7 +1149,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { stack: enhancedError.stack, }, }, - { status: 500 } + { status: isSystemError ? 500 : 422 } ) } diff --git a/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts b/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts index a369f9472a9..0e9c6a43e6c 100644 --- a/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts +++ b/apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts @@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -import { runSandboxTask } from '@/lib/execution/sandbox/run-task' +import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import type { SandboxTaskId } from '@/sandbox-tasks/registry' @@ -83,6 +83,14 @@ export function createDocumentPreviewRoute(config: DocumentPreviewRouteConfig) { }) } catch (err) { const message = toError(err).message + if (err instanceof SandboxUserCodeError) { + logger.warn(`${config.label} preview user code failed`, { + error: message, + errorName: err.name, + workspaceId, + }) + return NextResponse.json({ error: message, errorName: err.name }, { status: 422 }) + } logger.error(`${config.label} preview generation failed`, { error: message, workspaceId }) return NextResponse.json({ error: message }, { status: 500 }) } diff --git a/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts index cffe9cf9aef..6f14fd0649a 100644 --- a/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts @@ -6,9 +6,15 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -const { mockRunSandboxTask } = vi.hoisted(() => ({ - mockRunSandboxTask: vi.fn(), -})) +const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => { + class SandboxUserCodeError extends Error { + constructor(message: string, name: string) { + super(message) + this.name = name + } + } + return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError } +}) const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership @@ -16,6 +22,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/lib/execution/sandbox/run-task', () => ({ runSandboxTask: mockRunSandboxTask, + SandboxUserCodeError, })) import { POST } from '@/app/api/workspaces/[id]/docx/preview/route' @@ -189,4 +196,31 @@ describe('DOCX preview API route', () => { expect(response.status).toBe(500) await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) }) + + it('returns 422 when user code throws inside the sandbox', async () => { + mockRunSandboxTask.mockRejectedValue( + new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError') + ) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'const x = ' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(422) + await expect(response.json()).resolves.toEqual({ + error: 'Invalid or unexpected token', + errorName: 'SyntaxError', + }) + }) }) diff --git a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts index cf5bd49e454..2dd189f89c7 100644 --- a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts @@ -6,9 +6,15 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -const { mockRunSandboxTask } = vi.hoisted(() => ({ - mockRunSandboxTask: vi.fn(), -})) +const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => { + class SandboxUserCodeError extends Error { + constructor(message: string, name: string) { + super(message) + this.name = name + } + } + return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError } +}) const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership @@ -16,6 +22,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/lib/execution/sandbox/run-task', () => ({ runSandboxTask: mockRunSandboxTask, + SandboxUserCodeError, })) import { POST } from '@/app/api/workspaces/[id]/pdf/preview/route' @@ -187,4 +194,31 @@ describe('PDF preview API route', () => { expect(response.status).toBe(500) await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) }) + + it('returns 422 when user code throws inside the sandbox', async () => { + mockRunSandboxTask.mockRejectedValue( + new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError') + ) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'const x = ' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(422) + await expect(response.json()).resolves.toEqual({ + error: 'Invalid or unexpected token', + errorName: 'SyntaxError', + }) + }) }) diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts index 08a8e11f889..900dd41f639 100644 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts @@ -6,9 +6,15 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -const { mockRunSandboxTask } = vi.hoisted(() => ({ - mockRunSandboxTask: vi.fn(), -})) +const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => { + class SandboxUserCodeError extends Error { + constructor(message: string, name: string) { + super(message) + this.name = name + } + } + return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError } +}) const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership @@ -16,6 +22,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/lib/execution/sandbox/run-task', () => ({ runSandboxTask: mockRunSandboxTask, + SandboxUserCodeError, })) import { POST } from '@/app/api/workspaces/[id]/pptx/preview/route' @@ -189,4 +196,31 @@ describe('PPTX preview API route', () => { expect(response.status).toBe(500) await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) }) + + it('returns 422 when user code throws inside the sandbox', async () => { + mockRunSandboxTask.mockRejectedValue( + new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError') + ) + + const request = new NextRequest( + 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: 'const x = ' }), + } + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workspace-1' }), + }) + + expect(response.status).toBe(422) + await expect(response.json()).resolves.toEqual({ + error: 'Invalid or unexpected token', + errorName: 'SyntaxError', + }) + }) }) diff --git a/apps/sim/executor/orchestrators/loop.ts b/apps/sim/executor/orchestrators/loop.ts index 9816109a93d..1c089ac3cb3 100644 --- a/apps/sim/executor/orchestrators/loop.ts +++ b/apps/sim/executor/orchestrators/loop.ts @@ -717,10 +717,13 @@ export class LoopOrchestrator { }) if (vmResult.error) { - logger.error('Failed to evaluate loop condition', { + const isSystemError = vmResult.error.isSystemError === true + const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger) + logFn('Failed to evaluate loop condition', { condition, evaluatedCondition, error: vmResult.error, + isSystemError, }) return false } diff --git a/apps/sim/lib/execution/isolated-vm.ts b/apps/sim/lib/execution/isolated-vm.ts index 62221a1ea97..33301e671e7 100644 --- a/apps/sim/lib/execution/isolated-vm.ts +++ b/apps/sim/lib/execution/isolated-vm.ts @@ -99,6 +99,15 @@ export interface IsolatedVMError { line?: number column?: number lineContent?: string + /** + * True when the failure is host-infrastructure caused (worker crash, IPC + * failure, pool saturation, task misconfig) rather than anything the user's + * code did. Callers use this to keep genuine server failures as 5xx while + * translating user-caused failures (code errors, timeouts, aborts, per-owner + * rate limits) into 4xx. Defaults to undefined/false — new error sites + * default to user-caused unless explicitly marked. + */ + isSystemError?: boolean } const POOL_SIZE = Number.parseInt(env.IVM_POOL_SIZE) || 4 @@ -838,7 +847,11 @@ function cleanupWorker(workerId: number) { pending.resolve({ result: null, stdout: '', - error: { message: 'Code execution failed unexpectedly. Please try again.', name: 'Error' }, + error: { + message: 'Code execution failed unexpectedly. Please try again.', + name: 'Error', + isSystemError: true, + }, }) workerInfo.pendingExecutions.delete(id) } @@ -1125,7 +1138,11 @@ function dispatchToWorker( resolve({ result: null, stdout: '', - error: { message: 'Code execution failed to start. Please try again.', name: 'Error' }, + error: { + message: 'Code execution failed to start. Please try again.', + name: 'Error', + isSystemError: true, + }, }) if (workerInfo.retiring && workerInfo.activeExecutions === 0) { cleanupWorker(workerInfo.id) @@ -1159,6 +1176,7 @@ function enqueueExecution( error: { message: 'Code execution is at capacity. Please try again in a moment.', name: 'Error', + isSystemError: true, }, }) return @@ -1198,6 +1216,7 @@ function enqueueExecution( error: { message: 'Code execution timed out waiting for an available worker. Please try again.', name: 'Error', + isSystemError: true, }, }) }, QUEUE_TIMEOUT_MS) @@ -1294,6 +1313,7 @@ export async function executeInIsolatedVM( error: { message: `Task "${req.task.id}" requires broker "${brokerName}" but none was provided`, name: 'Error', + isSystemError: true, }, } } diff --git a/apps/sim/lib/execution/sandbox/run-task.ts b/apps/sim/lib/execution/sandbox/run-task.ts index c46a3251386..78cc6aadebf 100644 --- a/apps/sim/lib/execution/sandbox/run-task.ts +++ b/apps/sim/lib/execution/sandbox/run-task.ts @@ -20,6 +20,24 @@ export interface RunSandboxTaskOptions { signal?: AbortSignal } +/** + * Thrown when the sandbox failure is attributable to the caller — user code + * errors (SyntaxError, ReferenceError, user-thrown exceptions), timeouts from + * user code, client aborts, or per-owner rate limits. Callers should translate + * this into a 4xx response so genuine 5xx remains a signal of server health. + * + * System-origin failures (worker crash, IPC failure, pool saturation, task + * misconfig) are tagged with `isSystemError` at the isolated-vm layer and + * surface as a plain `Error` → 500. + */ +export class SandboxUserCodeError extends Error { + constructor(message: string, name: string, stack?: string) { + super(message) + this.name = name || 'SandboxUserCodeError' + if (stack) this.stack = stack + } +} + /** * Executes a sandbox task inside the shared isolated-vm pool and returns the * binary result buffer. Throws with a human-readable message if the task fails @@ -70,7 +88,9 @@ export async function runSandboxTask( const queueMs = result.timings ? Math.max(0, elapsedMs - result.timings.total) : undefined if (result.error) { - logger.warn('Sandbox task failed', { + const isSystemError = result.error.isSystemError === true + const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger) + logFn('Sandbox task failed', { taskId, requestId, workspaceId: input.workspaceId, @@ -79,11 +99,19 @@ export async function runSandboxTask( timings: result.timings, error: result.error.message, errorName: result.error.name, + isSystemError, }) - const err = new Error(result.error.message) - err.name = result.error.name || 'SandboxTaskError' - if (result.error.stack) err.stack = result.error.stack - throw err + if (isSystemError) { + const err = new Error(result.error.message) + err.name = result.error.name || 'SandboxSystemError' + if (result.error.stack) err.stack = result.error.stack + throw err + } + throw new SandboxUserCodeError( + result.error.message, + result.error.name || 'SandboxTaskError', + result.error.stack + ) } if (typeof result.bytesBase64 !== 'string' || result.bytesBase64.length === 0) { From 2e3de9ac8a1755a95b717a587f9179e6c343654d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 27 Apr 2026 22:07:41 -0700 Subject: [PATCH 09/28] feat(governance): external workspace users from outside org (#4313) * feat(governance): external workspace users from outside org * update docs * address comments * edge case improvements * remove unused fallback * address comments * add outbox for seat reduction * fix edge case with org join after invite * add server side batch invites for workspace * use zod schema for route --- .../content/docs/en/credentials/index.mdx | 4 +- .../docs/en/enterprise/access-control.mdx | 3 + .../content/docs/en/enterprise/audit-logs.mdx | 6 +- .../docs/content/docs/en/enterprise/index.mdx | 4 +- apps/docs/content/docs/en/enterprise/sso.mdx | 4 +- apps/docs/content/docs/en/execution/costs.mdx | 7 +- apps/docs/content/docs/en/mailer/index.mdx | 2 + .../en/permissions/roles-and-permissions.mdx | 17 +- .../content/docs/en/quick-reference/index.mdx | 2 +- apps/docs/content/docs/en/triggers/index.mdx | 2 + apps/docs/content/docs/en/variables/index.mdx | 4 +- .../app/api/invitations/[id]/resend/route.ts | 1 + apps/sim/app/api/invitations/[id]/route.ts | 8 + .../[id]/members/[memberId]/route.ts | 106 +- .../api/organizations/[id]/roster/route.ts | 79 +- .../app/api/organizations/[id]/seats/route.ts | 11 +- .../api/workspaces/invitations/batch/route.ts | 129 + .../api/workspaces/invitations/route.test.ts | 94 +- .../app/api/workspaces/invitations/route.ts | 263 +- .../app/api/workspaces/members/[id]/route.ts | 111 +- .../organization-roster.tsx | 21 +- .../remove-member-dialog.tsx | 33 +- .../transfer-ownership-dialog.tsx | 8 +- .../team-management/team-management.tsx | 24 +- .../components/permissions-table.tsx | 6 + .../invite-modal/components/types.ts | 1 + .../components/invite-modal/invite-modal.tsx | 44 +- apps/sim/hooks/queries/invitations.ts | 91 +- apps/sim/hooks/queries/organization.ts | 10 +- apps/sim/hooks/queries/workspace.ts | 1 + apps/sim/lib/billing/core/organization.ts | 19 +- .../lib/billing/organizations/membership.ts | 483 +- apps/sim/lib/billing/organizations/seats.ts | 130 + .../validation/seat-management.test.ts | 32 +- .../lib/billing/validation/seat-management.ts | 3 + .../lib/billing/webhooks/outbox-handlers.ts | 195 +- apps/sim/lib/core/telemetry.ts | 2 + apps/sim/lib/credentials/access.ts | 25 +- apps/sim/lib/invitations/core.test.ts | 209 + apps/sim/lib/invitations/core.ts | 42 +- apps/sim/lib/invitations/send.ts | 13 +- .../lib/invitations/workspace-invitations.ts | 310 + apps/sim/lib/posthog/events.ts | 1 + apps/sim/lib/workspaces/organization/types.ts | 1 + .../lib/workspaces/organization/utils.test.ts | 33 + apps/sim/lib/workspaces/organization/utils.ts | 4 +- .../lib/workspaces/permissions/utils.test.ts | 35 + apps/sim/lib/workspaces/permissions/utils.ts | 8 + apps/sim/lib/workspaces/policy.test.ts | 17 + apps/sim/lib/workspaces/policy.ts | 15 + .../0199_invitation_membership_intent.sql | 2 + .../db/migrations/meta/0199_snapshot.json | 15239 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 10 + packages/testing/src/mocks/schema.mock.ts | 2 + 55 files changed, 17444 insertions(+), 489 deletions(-) create mode 100644 apps/sim/app/api/workspaces/invitations/batch/route.ts create mode 100644 apps/sim/lib/billing/organizations/seats.ts create mode 100644 apps/sim/lib/invitations/core.test.ts create mode 100644 apps/sim/lib/invitations/workspace-invitations.ts create mode 100644 apps/sim/lib/workspaces/organization/utils.test.ts create mode 100644 packages/db/migrations/0199_invitation_membership_intent.sql create mode 100644 packages/db/migrations/meta/0199_snapshot.json diff --git a/apps/docs/content/docs/en/credentials/index.mdx b/apps/docs/content/docs/en/credentials/index.mdx index b5bd02b6d1f..5ad99772a29 100644 --- a/apps/docs/content/docs/en/credentials/index.mdx +++ b/apps/docs/content/docs/en/credentials/index.mdx @@ -25,6 +25,8 @@ Secrets are organized into two sections: - **Workspace** — shared with all members of your workspace - **Personal** — private to you +External workspace members count as workspace members for workspace-scoped secrets. They can use workspace secrets according to their workspace permission level, even though they are not members of your organization. + ### Adding a Secret Type a key name (e.g. `OPENAI_API_KEY`) into the **Key** column and its value into the **Value** column in the last empty row. A new empty row appears automatically as you type. Existing values are masked by default. @@ -89,7 +91,7 @@ Click **Save** to apply changes, or **Back** to return to the list. | | Workspace | Personal | |---|---|---| -| **Visibility** | All workspace members | Only you | +| **Visibility** | All workspace members, including external workspace members | Only you | | **Use in workflows** | Any member can use | Only you can use | | **Best for** | Production workflows, shared services | Testing, personal API keys | | **Who can edit** | Workspace admins | Only you | diff --git a/apps/docs/content/docs/en/enterprise/access-control.mdx b/apps/docs/content/docs/en/enterprise/access-control.mdx index 0034a296aef..38aa1ec8dac 100644 --- a/apps/docs/content/docs/en/enterprise/access-control.mdx +++ b/apps/docs/content/docs/en/enterprise/access-control.mdx @@ -130,6 +130,8 @@ Controls visibility of platform features and modules. Open the group's **Details** view and add members by searching for users by name or email. Only users who already have workspace-level access can be added. A user can only belong to one group per workspace — adding a user to a new group within the same workspace removes them from their current group for that workspace. +External workspace members are treated like other workspace members for access-control purposes. They can be assigned to permission groups in any workspace they have access to, but they do not become organization members or appear in the organization roster. + --- ## Enforcement @@ -159,6 +161,7 @@ When a user opens Mothership, their permission group is read before any block or - Moving a user to a new group within a workspace automatically removes them from their previous group in that workspace. - Users not assigned to any group in a workspace have no restrictions applied in that workspace (all blocks, providers, and features are available to them there). - If **Auto-add new members** is enabled on a group, new members of that workspace are automatically placed in the group. Only one group per workspace can have this setting active. +- External workspace members follow the same per-workspace permission group rules as internal members. --- diff --git a/apps/docs/content/docs/en/enterprise/audit-logs.mdx b/apps/docs/content/docs/en/enterprise/audit-logs.mdx index 92ec41a489e..9bcf9dfb0ed 100644 --- a/apps/docs/content/docs/en/enterprise/audit-logs.mdx +++ b/apps/docs/content/docs/en/enterprise/audit-logs.mdx @@ -44,7 +44,7 @@ Authorization: Bearer | `resourceType` | string | Filter by resource type (e.g. `workflow`) | | `resourceId` | string | Filter by a specific resource ID | | `workspaceId` | string | Filter by workspace | -| `actorId` | string | Filter by user ID (must be an org member) | +| `actorId` | string | Filter by user ID. For organization-wide filters, the actor must be a current or former org member; workspace-scoped logs can also include external workspace members. | | `startDate` | string | ISO 8601 date — return logs on or after this date | | `endDate` | string | ISO 8601 date — return logs on or before this date | | `includeDeparted` | boolean | Include logs from members who have since left the organization (default `false`) | @@ -98,6 +98,8 @@ Audit log events follow a `resource.action` naming pattern. The table below list | **Credentials** | `credential.created`, `credential.deleted`, `oauth.disconnected` | | **Organization** | `organization.updated`, `org_member.added`, `org_member.role_changed` | +Workspace invitation events include whether the invite is for an internal organization member or an external workspace member in their metadata. External workspace members can appear as actors on workspace-scoped events, but they are not organization members and do not appear in the organization roster. + --- diff --git a/apps/docs/content/docs/en/enterprise/index.mdx b/apps/docs/content/docs/en/enterprise/index.mdx index 1c01d7872fa..e4f004c62b7 100644 --- a/apps/docs/content/docs/en/enterprise/index.mdx +++ b/apps/docs/content/docs/en/enterprise/index.mdx @@ -13,6 +13,8 @@ Sim Enterprise provides advanced features for organizations with enhanced securi Define permission groups on a workspace to control what features and integrations its members can use. Permission groups are scoped to a single workspace — a user can belong to different groups (or no group) in different workspaces. +External workspace members can be assigned to permission groups just like internal organization members, but they remain outside the organization roster and do not consume seats. + ### Features - **Allowed Model Providers** - Restrict which AI providers users can access (OpenAI, Anthropic, Google, etc.) @@ -81,4 +83,4 @@ Self-hosted deployments enable enterprise features via environment variables ins | `INBOX_ENABLED`, `NEXT_PUBLIC_INBOX_ENABLED` | Sim Mailer inbox | | `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Disable invitations; manage membership via Admin API | -Once enabled, each feature is configured through the same Settings UI as Sim Cloud. When invitations are disabled, use the Admin API (`x-admin-key` header) to manage organization and workspace membership. +Once enabled, each feature is configured through the same Settings UI as Sim Cloud. When invitations are disabled, use the Admin API (`x-admin-key` header) to manage organization membership and workspace access. Internal members join the organization; external workspace members only receive access to a specific workspace. diff --git a/apps/docs/content/docs/en/enterprise/sso.mdx b/apps/docs/content/docs/en/enterprise/sso.mdx index ca04c0b9185..bfccc204122 100644 --- a/apps/docs/content/docs/en/enterprise/sso.mdx +++ b/apps/docs/content/docs/en/enterprise/sso.mdx @@ -221,6 +221,8 @@ Once SSO is configured, users with your domain (`company.com`) can sign in throu Users who sign in via SSO for the first time are automatically provisioned and added to your organization — no manual invite required. +SSO provisioning creates internal organization members. External workspace members are different: they are invited to a specific workspace without joining your organization or consuming one of your seats. + Password-based login remains available. Forcing all organization members to use SSO exclusively is not yet supported. @@ -242,7 +244,7 @@ Users who sign in via SSO for the first time are automatically provisioned and a }, { question: "What happens when a user signs in with SSO for the first time?", - answer: "Sim creates an account for them automatically and adds them to your organization. No manual invite is needed. They are assigned the member role by default." + answer: "Sim creates an account for them automatically and adds them to your organization. No manual invite is needed. They are assigned the member role by default. External workspace members are not provisioned through SSO into your organization; they are invited directly to a workspace and remain outside your org roster." }, { question: "Can I still use email/password login after enabling SSO?", diff --git a/apps/docs/content/docs/en/execution/costs.mdx b/apps/docs/content/docs/en/execution/costs.mdx index 3028a79f983..a08b8747901 100644 --- a/apps/docs/content/docs/en/execution/costs.mdx +++ b/apps/docs/content/docs/en/execution/costs.mdx @@ -272,6 +272,8 @@ Sim has two paid plan tiers - **Pro** and **Max**. Either can be used individual To use Pro or Max with a team, select **Get For Team** in subscription settings and choose the tier and number of seats. Credits are pooled across the organization at the per-seat rate (e.g. Max for Teams with 3 seats = 75,000 credits/mo pooled). +Internal organization members use seats and contribute to the team's pooled credit allocation. External workspace members do not join your organization, do not appear in the organization roster, and do not count toward your seat total. + ### Daily Refresh Credits Paid plans include a small daily credit allowance that does not count toward your plan limit. Each day, usage up to the daily refresh amount is excluded from billable usage. This allowance resets every 24 hours and does not carry over - use it or lose it. @@ -317,7 +319,7 @@ By default, your usage is capped at the credits included in your plan. To allow | **Max** | Up to 10 | — | | **Team / Enterprise** | Unlimited | Unlimited | -Team and Enterprise plans unlock shared workspaces that belong to your organization. Members invited to a shared workspace automatically join the organization and count toward your seat total. When a Team or Enterprise subscription is cancelled or downgraded, existing shared workspaces remain accessible to current members but new invites are disabled until the organization is upgraded again. +Team and Enterprise plans unlock shared workspaces that belong to your organization. Internal members invited to a shared workspace join the organization and count toward your seat total. Existing Sim users who already belong to another organization can be added as external workspace members; they get workspace access without joining your organization or using one of your seats. When a Team or Enterprise subscription is cancelled or downgraded, existing shared workspaces remain accessible to current members but new invites are disabled until the organization is upgraded again. ### Rate Limits @@ -368,7 +370,8 @@ Sim uses a **base subscription + overage** billing model: - Example: 7,000 credits used = $25 (subscription) + $5 (overage for 1,000 extra credits at $0.005/credit) **Team Plans:** -- Usage is pooled across all team members in the organization +- Usage is pooled across internal team members in the organization +- External workspace members keep their own organization or personal billing context for runs where they are the billing actor - Overage is calculated from total team usage against the pooled limit - Organization owner receives one bill diff --git a/apps/docs/content/docs/en/mailer/index.mdx b/apps/docs/content/docs/en/mailer/index.mdx index e21f979f285..420e481d688 100644 --- a/apps/docs/content/docs/en/mailer/index.mdx +++ b/apps/docs/content/docs/en/mailer/index.mdx @@ -42,6 +42,8 @@ Only authorized senders can create tasks. Emails from anyone else are automatica - **Workspace members** are allowed by default — no setup needed - **External senders** can be added manually with an optional label for easy identification +External senders are email addresses that can create inbox tasks. They are not the same as external workspace members, who have workspace access in Sim without joining your organization. + Manage your allowed senders list in **Settings** → **Inbox** → **Allowed Senders**. ## Tracking Tasks diff --git a/apps/docs/content/docs/en/permissions/roles-and-permissions.mdx b/apps/docs/content/docs/en/permissions/roles-and-permissions.mdx index fb048651eab..28cfe8df87e 100644 --- a/apps/docs/content/docs/en/permissions/roles-and-permissions.mdx +++ b/apps/docs/content/docs/en/permissions/roles-and-permissions.mdx @@ -12,7 +12,7 @@ When you invite team members to your organization or workspace, you'll need to c Sim has two kinds of workspaces: - **Personal workspaces** live under your individual account. The number you can create depends on your plan. -- **Shared (organization) workspaces** live under an organization and are available on Team and Enterprise plans. Any organization Owner or Admin can create them. Members invited to a shared workspace automatically join the organization and count toward your seat total. +- **Shared (organization) workspaces** live under an organization and are available on Team and Enterprise plans. Any organization Owner or Admin can create them. Internal members invited to a shared workspace join the organization and count toward your seat total. Existing Sim users who already belong to another organization can be added as external workspace members instead, giving them access to the workspace without adding them to your organization roster or using one of your seats. ### Workspace Limits by Plan @@ -43,6 +43,15 @@ When inviting someone to a workspace, you can assign one of three permission lev | **Write** | Create and edit workflows, run workflows, manage environment variables | | **Admin** | Everything Write can do, plus invite/remove users and manage workspace settings | +## Internal Members vs External Workspace Members + +Workspace permissions are separate from organization membership: + +- **Internal organization members** belong to your organization, appear in the organization roster, and count toward your seat total. Invite new teammates this way when they should be part of your company or team in Sim. +- **External workspace members** have access only to the workspace they are invited to. They keep their own organization membership, do not appear in your organization roster, and do not count toward your organization's seats. Use external access for clients, partners, contractors, or collaborators who already use Sim in another organization. + +External workspace members still receive a workspace permission level — Read, Write, or Admin — and that permission controls what they can do inside the workspace. + ## What Each Permission Level Can Do Here's a detailed breakdown of what users can do with each permission level: @@ -126,7 +135,7 @@ Every workspace has one **Owner** (the person who created it) plus any number of 2. **Workspace level**: Give them **Admin** permission so they can manage the team and see everything ### Adding a Stakeholder or Client -1. **Organization level**: Invite them as an **Organization Member** +1. **Organization level**: If they should not join your organization, add them as an **External workspace member** 2. **Workspace level**: Give them **Read** permission so they can see progress but not make changes --- @@ -199,12 +208,12 @@ An organization has three roles: **Owner**, **Admin**, and **Member**. import { FAQ } from '@/components/ui/faq' \ No newline at end of file diff --git a/apps/docs/content/docs/en/quick-reference/index.mdx b/apps/docs/content/docs/en/quick-reference/index.mdx index 1831918b835..f44ab7da0a8 100644 --- a/apps/docs/content/docs/en/quick-reference/index.mdx +++ b/apps/docs/content/docs/en/quick-reference/index.mdx @@ -31,7 +31,7 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
Invite team members - Sidebar → **Invite** + Sidebar → **Invite**. Internal invites join the organization; external workspace members get workspace access only.
diff --git a/apps/docs/content/docs/en/triggers/index.mdx b/apps/docs/content/docs/en/triggers/index.mdx index 99369685055..e996a39bc27 100644 --- a/apps/docs/content/docs/en/triggers/index.mdx +++ b/apps/docs/content/docs/en/triggers/index.mdx @@ -89,6 +89,8 @@ Polling Groups let you monitor multiple team members' Gmail or Outlook inboxes w Invitees receive an email with a link to connect their account. Once connected, their inbox is automatically included in the polling group. Invitees don't need to be members of your Sim organization. +This is separate from external workspace membership: polling group invitees are granting access to an inbox for a trigger, while external workspace members are collaborators with Read, Write, or Admin access to a workspace. + **Using in a Workflow** When configuring an email trigger, select your polling group from the credentials dropdown instead of an individual account. The system creates webhooks for each member and routes all emails through your workflow. diff --git a/apps/docs/content/docs/en/variables/index.mdx b/apps/docs/content/docs/en/variables/index.mdx index 0596e08a75c..d3543f368f7 100644 --- a/apps/docs/content/docs/en/variables/index.mdx +++ b/apps/docs/content/docs/en/variables/index.mdx @@ -49,7 +49,7 @@ Environment variables store sensitive values like API keys, tokens, and configur | Scope | Visibility | Use case | |-------|-----------|----------| -| **Workspace** | All workspace members | Shared API keys, team configuration | +| **Workspace** | All workspace members, including external workspace members | Shared API keys, team configuration | | **Personal** | Only you | Your personal tokens, dev credentials | When both a workspace and personal variable share the same key, the workspace value takes precedence. @@ -84,7 +84,7 @@ If a workflow variable and a block output share the same name, Sim resolves the syntax. Environment variables store sensitive configuration like API keys using {{KEY}} syntax. They never appear in logs and are managed at the workspace or personal level." }, { question: "Can I use environment variables in the Function block?", answer: "Yes. Use the double curly brace syntax {{KEY}} directly in your code. The value is substituted before execution, so the actual secret never appears in logs or outputs." }, - { question: "How do I share an API key with my team?", answer: "Create a workspace-scoped environment variable in Settings → Secrets. All workspace members will be able to use it in their workflows via {{KEY}} syntax." }, + { question: "How do I share an API key with my team?", answer: "Create a workspace-scoped environment variable in Settings → Secrets. All workspace members, including external workspace members, will be able to use it in their workflows via {{KEY}} syntax." }, { question: "What happens if a variable name has spaces or mixed case?", answer: "Variable resolution is case-insensitive and ignores spaces. A variable named 'My Counter' can be referenced as or . However, using consistent naming (like camelCase) is recommended." }, { question: "Can I reference environment variables in the Agent system prompt?", answer: "Yes. You can use {{KEY}} syntax in any text field, including system prompts, to inject environment variable values." }, ]} /> diff --git a/apps/sim/app/api/invitations/[id]/resend/route.ts b/apps/sim/app/api/invitations/[id]/resend/route.ts index 1841f93118a..99c0721844d 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.ts @@ -146,6 +146,7 @@ export const POST = withRouteHandler( targetEmail: inv.email, targetRole: inv.role, kind: inv.kind, + membershipIntent: inv.membershipIntent, }, request, }) diff --git a/apps/sim/app/api/invitations/[id]/route.ts b/apps/sim/app/api/invitations/[id]/route.ts index 8e08cfc89dc..532a3c2cbb6 100644 --- a/apps/sim/app/api/invitations/[id]/route.ts +++ b/apps/sim/app/api/invitations/[id]/route.ts @@ -54,6 +54,7 @@ export const GET = withRouteHandler( email: inv.email, organizationId: inv.organizationId, organizationName: inv.organizationName, + membershipIntent: inv.membershipIntent, role: inv.role, status: inv.status, expiresAt: inv.expiresAt, @@ -121,6 +122,12 @@ export const PATCH = withRouteHandler( const { role, grants } = parsed.data if (role !== undefined) { + if (inv.membershipIntent === 'external') { + return NextResponse.json( + { error: 'Role updates are not valid on external workspace invitations' }, + { status: 400 } + ) + } if (!inv.organizationId) { return NextResponse.json( { error: 'Role updates are only valid on organization-scoped invitations' }, @@ -187,6 +194,7 @@ export const PATCH = withRouteHandler( invitationId: id, targetEmail: inv.email, kind: inv.kind, + membershipIntent: inv.membershipIntent, roleUpdate: role ?? null, grantUpdates: grantsToApply, }, 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 971b1e57c79..f672686ac7d 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -8,7 +8,11 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { getUserUsageData } from '@/lib/billing/core/usage' -import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' +import { + removeExternalUserFromOrganizationWorkspaces, + removeUserFromOrganization, +} from '@/lib/billing/organizations/membership' +import { reduceOrganizationSeatsByOne } from '@/lib/billing/organizations/seats' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('OrganizationMemberAPI') @@ -282,6 +286,7 @@ export const DELETE = withRouteHandler( } const { id: organizationId, memberId: targetUserId } = await params + const shouldReduceSeats = request.nextUrl.searchParams.get('shouldReduceSeats') === 'true' const userMember = await db .select() @@ -311,7 +316,79 @@ export const DELETE = withRouteHandler( .limit(1) if (targetMember.length === 0) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + const [targetUser] = await db + .select({ id: user.id, email: user.email, name: user.name }) + .from(user) + .where(eq(user.id, targetUserId)) + .limit(1) + + if (!targetUser) { + return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + } + + const externalResult = await removeExternalUserFromOrganizationWorkspaces({ + userId: targetUserId, + organizationId, + }) + + if (!externalResult.success) { + const error = externalResult.error || 'External workspace member not found' + const status = + error === 'External workspace member not found' + ? 404 + : error === 'User is an organization member' + ? 409 + : 500 + + return NextResponse.json({ error }, { status }) + } + + logger.info('External workspace member removed from organization workspaces', { + organizationId, + removedMemberId: targetUserId, + removedBy: session.user.id, + workspaceAccessRevoked: externalResult.workspaceAccessRevoked, + permissionGroupsRevoked: externalResult.permissionGroupsRevoked, + credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked, + pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled, + }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_MEMBER_REMOVED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Removed external workspace member ${targetUserId} from organization`, + metadata: { + targetUserId, + targetEmail: targetUser.email ?? undefined, + targetName: targetUser.name ?? undefined, + membershipType: 'external', + workspaceAccessRevoked: externalResult.workspaceAccessRevoked, + permissionGroupsRevoked: externalResult.permissionGroupsRevoked, + credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked, + pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled, + }, + request, + }) + + return NextResponse.json({ + success: true, + message: 'External member removed successfully', + data: { + removedMemberId: targetUserId, + removedBy: session.user.id, + removedAt: new Date().toISOString(), + membershipType: 'external', + workspaceAccessRevoked: externalResult.workspaceAccessRevoked, + permissionGroupsRevoked: externalResult.permissionGroupsRevoked, + credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked, + pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled, + }, + }) } const result = await removeUserFromOrganization({ @@ -330,6 +407,28 @@ 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', + } + } + } + if (session.user.id === targetUserId) { try { await setActiveOrganizationForCurrentSession(null) @@ -348,6 +447,7 @@ export const DELETE = withRouteHandler( removedBy: session.user.id, wasSelfRemoval: session.user.id === targetUserId, billingActions: result.billingActions, + seatReduction, }) recordAudit({ @@ -367,6 +467,7 @@ export const DELETE = withRouteHandler( targetEmail: targetMember[0].email ?? undefined, targetName: targetMember[0].name ?? undefined, wasSelfRemoval: session.user.id === targetUserId, + seatReduction, }, request, }) @@ -381,6 +482,7 @@ export const DELETE = withRouteHandler( removedMemberId: targetUserId, removedBy: session.user.id, removedAt: new Date().toISOString(), + seatReduction, }, }) } catch (error) { diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index c1abe4d6d15..86229747db7 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -8,7 +8,7 @@ import { workspace, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -57,7 +57,7 @@ export const GET = withRouteHandler( const orgWorkspaces = await db .select({ id: workspace.id, name: workspace.name }) .from(workspace) - .where(eq(workspace.organizationId, organizationId)) + .where(and(eq(workspace.organizationId, organizationId), isNull(workspace.archivedAt))) const orgWorkspaceIds = orgWorkspaces.map((ws) => ws.id) const workspaceNameById = new Map(orgWorkspaces.map((ws) => [ws.id, ws.name])) @@ -118,12 +118,82 @@ export const GET = withRouteHandler( workspaces: permissionsByUser.get(row.userId) ?? [], })) + const externalPermissionRows = + orgWorkspaceIds.length > 0 + ? await db + .select({ + userId: user.id, + userName: user.name, + userEmail: user.email, + userImage: user.image, + workspaceId: permissions.entityId, + permission: permissions.permissionType, + createdAt: permissions.createdAt, + }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .leftJoin( + member, + and(eq(member.userId, user.id), eq(member.organizationId, organizationId)) + ) + .where( + and( + eq(permissions.entityType, 'workspace'), + inArray(permissions.entityId, orgWorkspaceIds), + isNull(member.id) + ) + ) + : [] + + const externalMembersByUser = new Map< + string, + { + memberId: string + userId: string + role: 'external' + createdAt: Date + name: string + email: string + image: string | null + workspaces: RosterWorkspaceAccess[] + } + >() + + for (const row of externalPermissionRows) { + const existing = externalMembersByUser.get(row.userId) + const workspaceAccess: RosterWorkspaceAccess = { + workspaceId: row.workspaceId, + workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace', + permission: row.permission, + } + + if (existing) { + existing.workspaces.push(workspaceAccess) + if (row.createdAt < existing.createdAt) existing.createdAt = row.createdAt + continue + } + + externalMembersByUser.set(row.userId, { + memberId: `external-${row.userId}`, + userId: row.userId, + role: 'external', + createdAt: row.createdAt, + name: row.userName, + email: row.userEmail, + image: row.userImage, + workspaces: [workspaceAccess], + }) + } + + const rosterMembers = [...members, ...externalMembersByUser.values()] + const pendingInvitationRows = await db .select({ id: invitation.id, email: invitation.email, role: invitation.role, kind: invitation.kind, + membershipIntent: invitation.membershipIntent, createdAt: invitation.createdAt, expiresAt: invitation.expiresAt, inviteeName: user.name, @@ -160,8 +230,9 @@ export const GET = withRouteHandler( const pendingInvitations = pendingInvitationRows.map((row) => ({ id: row.id, email: row.email, - role: row.role, + role: row.membershipIntent === 'external' ? 'external' : row.role, kind: row.kind, + membershipIntent: row.membershipIntent, createdAt: row.createdAt, expiresAt: row.expiresAt, inviteeName: row.inviteeName, @@ -172,7 +243,7 @@ export const GET = withRouteHandler( return NextResponse.json({ success: true, data: { - members, + members: rosterMembers, pendingInvitations, workspaces: orgWorkspaces, }, diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index 0cfba281c62..cce91dfc8a8 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { invitation, member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, count, eq, inArray } from 'drizzle-orm' +import { and, count, eq, gt, inArray, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' @@ -116,7 +116,14 @@ export const PUT = withRouteHandler( const [pendingCountRow] = await db .select({ count: count() }) .from(invitation) - .where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'))) + .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 diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.ts b/apps/sim/app/api/workspaces/invitations/batch/route.ts new file mode 100644 index 00000000000..1f48746dd5a --- /dev/null +++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts @@ -0,0 +1,129 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { normalizeEmail } from '@/lib/invitations/core' +import { + createWorkspaceInvitation, + prepareWorkspaceInvitationContext, + WorkspaceInvitationError, + type WorkspaceInvitationResult, +} from '@/lib/invitations/workspace-invitations' +import { InvitationsNotAllowedError } from '@/ee/access-control/utils/permission-check' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('WorkspaceInvitationBatchAPI') + +interface BatchInvitationFailure { + email: string + error: string +} + +const batchInvitationSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + invitations: z + .array( + z.object({ + email: z.string().trim().min(1, 'Invitation email is required'), + permission: z.string().optional(), + }) + ) + .min(1, 'At least one invitation is required'), +}) + +type BatchInvitationRequest = z.infer + +function batchErrorResponse(error: unknown) { + if (error instanceof WorkspaceInvitationError) { + return NextResponse.json( + { + error: error.message, + ...(error.email ? { email: error.email } : {}), + ...(error.upgradeRequired !== undefined ? { upgradeRequired: error.upgradeRequired } : {}), + }, + { status: error.status } + ) + } + + if (error instanceof InvitationsNotAllowedError) { + return NextResponse.json({ error: error.message }, { status: 403 }) + } + + logger.error('Error creating workspace invitation batch:', error) + return NextResponse.json({ error: 'Failed to create invitation batch' }, { status: 500 }) +} + +export const POST = withRouteHandler(async (req: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const parsedBody = batchInvitationSchema.safeParse(await req.json().catch(() => null)) + if (!parsedBody.success) { + return NextResponse.json( + { error: parsedBody.error.errors[0]?.message ?? 'Invalid invitation batch payload' }, + { status: 400 } + ) + } + const body: BatchInvitationRequest = parsedBody.data + + const context = await prepareWorkspaceInvitationContext({ + workspaceId: body.workspaceId, + inviterId: session.user.id, + inviterName: session.user.name || session.user.email || 'A user', + inviterEmail: session.user.email, + }) + + const successful: string[] = [] + const failed: BatchInvitationFailure[] = [] + const invitations: WorkspaceInvitationResult[] = [] + const seenEmails = new Set() + + for (const item of body.invitations) { + const normalizedEmail = normalizeEmail(item.email) + if (seenEmails.has(normalizedEmail)) { + failed.push({ + email: normalizedEmail, + error: `${normalizedEmail} appears more than once in this invitation batch`, + }) + continue + } + seenEmails.add(normalizedEmail) + + try { + const invitation = await createWorkspaceInvitation({ + context, + email: item.email, + permission: item.permission, + request: req, + }) + successful.push(invitation.email) + invitations.push(invitation) + } catch (error) { + if (error instanceof WorkspaceInvitationError) { + failed.push({ email: error.email ?? normalizedEmail, error: error.message }) + continue + } + + logger.error('Unexpected workspace invitation batch item failure:', { + email: normalizedEmail, + error, + }) + throw error + } + } + + return NextResponse.json({ + success: failed.length === 0, + successful, + failed, + invitations, + }) + } catch (error) { + return batchErrorResponse(error) + } +}) diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index e15b4236061..979fe7523bc 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -108,9 +108,9 @@ const mockGetSession = authMockFns.mockGetSession const mockGetWorkspaceWithOwner = permissionsMockFns.mockGetWorkspaceWithOwner import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants' -import { POST } from '@/app/api/workspaces/invitations/route' +import { POST } from '@/app/api/workspaces/invitations/batch/route' -describe('POST /api/workspaces/invitations', () => { +describe('POST /api/workspaces/invitations/batch', () => { beforeEach(() => { vi.clearAllMocks() mockDbResults.value = [] @@ -169,8 +169,7 @@ describe('POST /api/workspaces/invitations', () => { const request = createMockRequest('POST', { workspaceId: 'workspace-1', - email: 'new@example.com', - permission: 'read', + invitations: [{ email: 'new@example.com', permission: 'read' }], }) const response = await POST(request) @@ -201,8 +200,7 @@ describe('POST /api/workspaces/invitations', () => { const request = createMockRequest('POST', { workspaceId: 'workspace-1', - email: 'new@example.com', - permission: 'read', + invitations: [{ email: 'new@example.com', permission: 'read' }], }) const response = await POST(request) @@ -213,7 +211,7 @@ describe('POST /api/workspaces/invitations', () => { expect(mockCreatePendingInvitation).not.toHaveBeenCalled() }) - it('rejects org-owned invites when the organization has no available seats', async () => { + it('reports org-owned invites as failed when the organization has no available seats', async () => { mockGetWorkspaceWithOwner.mockResolvedValueOnce({ id: 'workspace-1', name: 'Org Workspace', @@ -240,20 +238,25 @@ describe('POST /api/workspaces/invitations', () => { const request = createMockRequest('POST', { workspaceId: 'workspace-1', - email: 'new@example.com', - permission: 'read', + invitations: [{ email: 'new@example.com', permission: 'read' }], }) const response = await POST(request) const data = await response.json() - expect(response.status).toBe(400) - expect(data.error).toContain('No available seats') + expect(response.status).toBe(200) + expect(data.success).toBe(false) + expect(data.failed).toEqual([ + { + email: 'new@example.com', + error: 'No available seats. Currently using 5 of 5 seats.', + }, + ]) expect(mockValidateSeatAvailability).toHaveBeenCalledWith('org-1', 1) expect(mockCreatePendingInvitation).not.toHaveBeenCalled() }) - it('rejects org-owned invites for users already in another organization', async () => { + it('creates an external workspace invitation for users already in another organization', async () => { mockGetWorkspaceWithOwner.mockResolvedValueOnce({ id: 'workspace-1', name: 'Org Workspace', @@ -281,16 +284,25 @@ describe('POST /api/workspaces/invitations', () => { const request = createMockRequest('POST', { workspaceId: 'workspace-1', - email: 'new@example.com', - permission: 'read', + invitations: [{ email: 'new@example.com', permission: 'read' }], }) const response = await POST(request) const data = await response.json() - expect(response.status).toBe(409) - expect(data.error).toContain('already a member of another organization') - expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.invitations[0].membershipIntent).toBe('external') + expect(mockValidateSeatAvailability).not.toHaveBeenCalled() + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'workspace', + email: 'new@example.com', + organizationId: 'org-1', + membershipIntent: 'external', + grants: [{ workspaceId: 'workspace-1', permission: 'read' }], + }) + ) }) it('creates a unified workspace invitation for a grandfathered workspace', async () => { @@ -306,8 +318,7 @@ describe('POST /api/workspaces/invitations', () => { const request = createMockRequest('POST', { workspaceId: 'workspace-1', - email: 'new@example.com', - permission: 'write', + invitations: [{ email: 'new@example.com', permission: 'write' }], }) const response = await POST(request) @@ -327,6 +338,40 @@ describe('POST /api/workspaces/invitations', () => { expect(mockValidateSeatAvailability).not.toHaveBeenCalled() }) + it('creates multiple workspace invitations in one batch request', async () => { + mockDbResults.value = [[{ permissionType: 'admin' }], [], []] + mockCreatePendingInvitation + .mockResolvedValueOnce({ + invitationId: 'inv-1', + token: 'tok-1', + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }) + .mockResolvedValueOnce({ + invitationId: 'inv-2', + token: 'tok-2', + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }) + + const request = createMockRequest('POST', { + workspaceId: 'workspace-1', + invitations: [ + { email: 'first@example.com', permission: 'read' }, + { email: 'second@example.com', permission: 'write' }, + ], + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.successful).toEqual(['first@example.com', 'second@example.com']) + expect(data.failed).toEqual([]) + expect(data.invitations).toHaveLength(2) + expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(2) + expect(mockSendInvitationEmail).toHaveBeenCalledTimes(2) + }) + it('rolls back the unified invitation when email delivery fails', async () => { mockGetWorkspaceWithOwner.mockResolvedValueOnce({ id: 'workspace-1', @@ -344,13 +389,18 @@ describe('POST /api/workspaces/invitations', () => { const request = createMockRequest('POST', { workspaceId: 'workspace-1', - email: 'new@example.com', - permission: 'read', + invitations: [{ email: 'new@example.com', permission: 'read' }], }) const response = await POST(request) - expect(response.status).toBe(502) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ + success: false, + failed: [{ email: 'new@example.com', error: 'mailer unavailable' }], + }) + ) expect(mockCancelPendingInvitation).toHaveBeenCalledWith('inv-1') }) }) diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index a994d6daa48..378b169ad66 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -1,35 +1,16 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { permissions, type permissionTypeEnum, user, workspace } from '@sim/db/schema' +import { permissions, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, isNull, sql } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { getUserOrganization } from '@/lib/billing/organizations/membership' -import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' -import { PlatformEvents } from '@/lib/core/telemetry' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { listInvitationsForWorkspaces, normalizeEmail } from '@/lib/invitations/core' -import { - cancelPendingInvitation, - createPendingInvitation, - findPendingGrantForWorkspaceEmail, - sendInvitationEmail, -} from '@/lib/invitations/send' -import { captureServerEvent } from '@/lib/posthog/server' -import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' -import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' -import { - InvitationsNotAllowedError, - validateInvitationsAllowed, -} from '@/ee/access-control/utils/permission-check' +import { listInvitationsForWorkspaces } from '@/lib/invitations/core' export const dynamic = 'force-dynamic' const logger = createLogger('WorkspaceInvitationsAPI') -type PermissionType = (typeof permissionTypeEnum.enumValues)[number] - export const GET = withRouteHandler(async (req: NextRequest) => { const session = await getSession() if (!session?.user?.id) { @@ -61,241 +42,3 @@ export const GET = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 }) } }) - -export const POST = withRouteHandler(async (req: NextRequest) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const { workspaceId, email, permission = 'read' } = await req.json() - - if (!workspaceId || !email) { - return NextResponse.json({ error: 'Workspace ID and email are required' }, { status: 400 }) - } - - await validateInvitationsAllowed(session.user.id, workspaceId) - - const validPermissions: PermissionType[] = ['admin', 'write', 'read'] - if (!validPermissions.includes(permission)) { - return NextResponse.json( - { error: `Invalid permission: must be one of ${validPermissions.join(', ')}` }, - { status: 400 } - ) - } - - const normalizedEmail = normalizeEmail(email) - - const userPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityId, workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, session.user.id), - eq(permissions.permissionType, 'admin') - ) - ) - .then((rows) => rows[0]) - - if (!userPermission) { - return NextResponse.json( - { error: 'You need admin permissions to invite users' }, - { status: 403 } - ) - } - - const workspaceDetails = await getWorkspaceWithOwner(workspaceId) - if (!workspaceDetails) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - const invitePolicy = await getWorkspaceInvitePolicy(workspaceDetails) - if (!invitePolicy.allowed) { - return NextResponse.json( - { - error: invitePolicy.reason ?? 'Invites are disabled for this workspace.', - upgradeRequired: invitePolicy.upgradeRequired, - }, - { status: 403 } - ) - } - - const existingUser = await db - .select() - .from(user) - .where(sql`lower(${user.email}) = ${normalizedEmail}`) - .then((rows) => rows[0]) - - if (existingUser) { - const existingPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityId, workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, existingUser.id) - ) - ) - .then((rows) => rows[0]) - - if (existingPermission) { - return NextResponse.json( - { - error: `${normalizedEmail} already has access to this workspace`, - email: normalizedEmail, - }, - { status: 400 } - ) - } - - if (invitePolicy.requiresSeat && invitePolicy.organizationId) { - const existingMembership = await getUserOrganization(existingUser.id) - if ( - existingMembership && - existingMembership.organizationId !== invitePolicy.organizationId - ) { - return NextResponse.json( - { - error: - 'This user is already a member of another organization. They must leave it before joining this workspace.', - email: normalizedEmail, - }, - { status: 409 } - ) - } - - if (!existingMembership) { - const seatValidation = await validateSeatAvailability(invitePolicy.organizationId, 1) - if (!seatValidation.canInvite) { - return NextResponse.json( - { - error: seatValidation.reason || 'No available seats for this organization.', - email: normalizedEmail, - }, - { status: 400 } - ) - } - } - } - } else if (invitePolicy.requiresSeat && invitePolicy.organizationId) { - const seatValidation = await validateSeatAvailability(invitePolicy.organizationId, 1) - if (!seatValidation.canInvite) { - return NextResponse.json( - { - error: seatValidation.reason || 'No available seats for this organization.', - email: normalizedEmail, - }, - { status: 400 } - ) - } - } - - const existingInvitation = await findPendingGrantForWorkspaceEmail({ - workspaceId, - email: normalizedEmail, - }) - if (existingInvitation) { - return NextResponse.json( - { - error: `${normalizedEmail} has already been invited to this workspace`, - email: normalizedEmail, - }, - { status: 400 } - ) - } - - const { invitationId, token } = await createPendingInvitation({ - kind: 'workspace', - email: normalizedEmail, - inviterId: session.user.id, - organizationId: workspaceDetails.organizationId, - role: 'member', - grants: [ - { - workspaceId, - permission, - }, - ], - }) - - try { - PlatformEvents.workspaceMemberInvited({ - workspaceId, - invitedBy: session.user.id, - inviteeEmail: normalizedEmail, - role: permission, - }) - } catch { - // telemetry must not fail the operation - } - - captureServerEvent( - session.user.id, - 'workspace_member_invited', - { workspace_id: workspaceId, invitee_role: permission }, - { - groups: { workspace: workspaceId }, - setOnce: { first_invitation_sent_at: new Date().toISOString() }, - } - ) - - const emailResult = await sendInvitationEmail({ - invitationId, - token, - kind: 'workspace', - email: normalizedEmail, - inviterName: session.user.name || session.user.email || 'A user', - organizationId: workspaceDetails.organizationId, - organizationRole: 'member', - grants: [{ workspaceId, permission }], - }) - - if (!emailResult.success) { - await cancelPendingInvitation(invitationId) - return NextResponse.json( - { error: emailResult.error || 'Failed to send invitation email' }, - { status: 502 } - ) - } - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.MEMBER_INVITED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - resourceName: normalizedEmail, - description: `Invited ${normalizedEmail} as ${permission}`, - metadata: { - targetEmail: normalizedEmail, - targetRole: permission, - workspaceName: workspaceDetails.name, - invitationId, - }, - request: req, - }) - - return NextResponse.json({ - success: true, - invitation: { - id: invitationId, - workspaceId, - email: normalizedEmail, - permission, - expiresAt: undefined, - }, - }) - } catch (error) { - if (error instanceof InvitationsNotAllowedError) { - return NextResponse.json({ error: error.message }, { status: 403 }) - } - logger.error('Error creating workspace invitation:', error) - return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index 43add66c447..b178c4c2905 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -1,13 +1,14 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { permissions, workspace } from '@sim/db/schema' +import { permissionGroupMember, permissions, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access' +import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' import { captureServerEvent } from '@/lib/posthog/server' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' @@ -32,7 +33,10 @@ export const DELETE = withRouteHandler( const { workspaceId } = body const workspaceRow = await db - .select({ billedAccountUserId: workspace.billedAccountUserId }) + .select({ + ownerId: workspace.ownerId, + billedAccountUserId: workspace.billedAccountUserId, + }) .from(workspace) .where(eq(workspace.id, workspaceId)) .limit(1) @@ -61,7 +65,10 @@ export const DELETE = withRouteHandler( ) .then((rows) => rows[0]) - if (!userPermission) { + const isRemovingWorkspaceOwner = workspaceRow[0].ownerId === userId + const isOwnerOnlyRemoval = isRemovingWorkspaceOwner && !userPermission + + if (!userPermission && !isOwnerOnlyRemoval) { return NextResponse.json({ error: 'User not found in workspace' }, { status: 404 }) } @@ -73,8 +80,19 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } + if ( + isRemovingWorkspaceOwner && + !isSelf && + session.user.id !== workspaceRow[0].billedAccountUserId + ) { + return NextResponse.json( + { error: 'Only the workspace owner or billing account can remove the workspace owner' }, + { status: 403 } + ) + } + // Prevent removing yourself if you're the last admin - if (isSelf && userPermission.permissionType === 'admin') { + if (isSelf && userPermission?.permissionType === 'admin' && !isRemovingWorkspaceOwner) { const otherAdmins = await db .select() .from(permissions) @@ -95,18 +113,78 @@ export const DELETE = withRouteHandler( } } - // Delete the user's permissions for this workspace - await db - .delete(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) + const ownershipTransferred = await db.transaction(async (tx) => { + let didTransferOwnership = false + + if (isRemovingWorkspaceOwner) { + /** + * Invariant: the billed account is the org owner for org workspaces, + * the owner for personal workspaces, and a workspace admin for + * grandfathered shared workspaces. + */ + const newOwnerId = workspaceRow[0].billedAccountUserId + + await tx + .update(workspace) + .set({ ownerId: newOwnerId, updatedAt: new Date() }) + .where(eq(workspace.id, workspaceId)) + + const [existingNewOwnerPermission] = await tx + .select({ id: permissions.id }) + .from(permissions) + .where( + and( + eq(permissions.userId, newOwnerId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .limit(1) + + if (existingNewOwnerPermission) { + await tx + .update(permissions) + .set({ permissionType: 'admin', updatedAt: new Date() }) + .where(eq(permissions.id, existingNewOwnerPermission.id)) + } else { + const now = new Date() + await tx.insert(permissions).values({ + id: generateId(), + userId: newOwnerId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + createdAt: now, + updatedAt: now, + }) + } + + didTransferOwnership = true + } + + await tx + .delete(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) ) - ) - await revokeWorkspaceCredentialMemberships(workspaceId, userId) + await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId) + + await tx + .delete(permissionGroupMember) + .where( + and( + eq(permissionGroupMember.userId, userId), + eq(permissionGroupMember.workspaceId, workspaceId) + ) + ) + + return didTransferOwnership + }) captureServerEvent( session.user.id, @@ -126,8 +204,9 @@ export const DELETE = withRouteHandler( description: isSelf ? 'Left the workspace' : `Removed member ${userId} from the workspace`, metadata: { removedUserId: userId, - removedUserRole: userPermission.permissionType, + removedUserRole: userPermission?.permissionType ?? 'owner', selfRemoval: isSelf, + ownershipTransferred, }, request: req, }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-roster/organization-roster.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-roster/organization-roster.tsx index e448baf11fe..fd906795894 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-roster/organization-roster.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-roster/organization-roster.tsx @@ -75,11 +75,11 @@ function apportionCredits( return result } -function RoleBadge({ role }: { role: string }) { - const variant = role === 'owner' ? 'blue-secondary' : 'gray-secondary' +function RoleBadge({ memberRole }: { memberRole: string }) { + const variant = memberRole === 'owner' ? 'blue-secondary' : 'gray-secondary' return ( - {role.charAt(0).toUpperCase() + role.slice(1)} + {memberRole.charAt(0).toUpperCase() + memberRole.slice(1)} ) } @@ -521,6 +521,7 @@ export function OrganizationRoster({ const rowKey = `member-${m.memberId}` const expanded = expandedRows.has(rowKey) const isSelf = m.email === currentUserEmail + const isExternal = m.role === 'external' const credits = memberCredits[m.userId] ?? 0 const canRemove = isAdminOrOwner && m.role !== 'owner' && !isSelf const canTransferAndLeave = isSelf && m.role === 'owner' && !!onTransferOwnership @@ -545,8 +546,11 @@ export function OrganizationRoster({ - {m.role === 'owner' || !canEditRoles || m.userId === currentUserId ? ( - + {m.role === 'owner' || + isExternal || + !canEditRoles || + m.userId === currentUserId ? ( + ) : ( { const rowKey = `invite-${inv.id}` const expanded = expandedRows.has(rowKey) + const isExternal = inv.membershipIntent === 'external' const isResending = resendingIds.has(inv.id) const isCancelling = cancellingIds.has(inv.id) const cooldown = resendCooldowns[inv.id] ?? 0 @@ -660,7 +665,9 @@ export function OrganizationRoster({ - {isAdminOrOwner ? ( + {isExternal ? ( + + ) : isAdminOrOwner ? ( @@ -677,7 +684,7 @@ export function OrganizationRoster({ disabled={updateInvitation.isPending} /> ) : ( - + )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx index f6cb01f48a9..361ed403182 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx @@ -12,7 +12,10 @@ interface RemoveMemberDialogProps { open: boolean memberName: string shouldReduceSeats: boolean + canReduceSeats?: boolean isSelfRemoval?: boolean + isExternalRemoval?: boolean + isSubmitting?: boolean error?: Error | null onOpenChange: (open: boolean) => void onShouldReduceSeatsChange: (shouldReduce: boolean) => void @@ -24,21 +27,37 @@ export function RemoveMemberDialog({ open, memberName, shouldReduceSeats, + canReduceSeats = true, error, onOpenChange, onShouldReduceSeatsChange, onConfirmRemove, onCancel, isSelfRemoval = false, + isExternalRemoval = false, + isSubmitting = false, }: RemoveMemberDialogProps) { + const title = isSelfRemoval + ? 'Leave Organization' + : isExternalRemoval + ? 'Remove External Member' + : 'Remove Team Member' + return ( - {isSelfRemoval ? 'Leave Organization' : 'Remove Team Member'} + {title}

{isSelfRemoval ? ( 'Are you sure you want to leave this organization? You will lose access to all team resources.' + ) : isExternalRemoval ? ( + <> + Are you sure you want to remove{' '} + {memberName} from + all organization workspaces? Their workspace access and workspace credential access + will be revoked. + ) : ( <> Are you sure you want to remove{' '} @@ -49,7 +68,7 @@ export function RemoveMemberDialog({ This action cannot be undone.

- {!isSelfRemoval && ( + {!isSelfRemoval && !isExternalRemoval && canReduceSeats && (
- - diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx index c417e8b5226..e3620d31ad9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx @@ -53,7 +53,9 @@ export function TransferOwnershipDialog({ const [selectedUserId, setSelectedUserId] = useState(null) const candidates = useMemo(() => { - const others = members.filter((m) => m.userId !== currentUserId && m.role !== 'owner') + const others = members.filter( + (m) => m.userId !== currentUserId && m.role !== 'owner' && m.role !== 'external' + ) others.sort((a, b) => { if (a.role === 'admin' && b.role !== 'admin') return -1 if (a.role !== 'admin' && b.role === 'admin') return 1 @@ -66,7 +68,9 @@ export function TransferOwnershipDialog({ ) }, [members, currentUserId, search]) - const hasCandidates = members.some((m) => m.userId !== currentUserId && m.role !== 'owner') + const hasCandidates = members.some( + (m) => m.userId !== currentUserId && m.role !== 'owner' && m.role !== 'external' + ) const handleClose = (next: boolean) => { if (!next) { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index dbd86c9f498..11d35400340 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -6,14 +6,9 @@ import { Skeleton, type TagItem } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client/utils' import { getPlanTierCredits, getPlanTierDollars } from '@/lib/billing/plan-helpers' -import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils' +import { checkEnterprisePlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils' import { getBaseUrl } from '@/lib/core/utils/urls' -import { - generateSlug, - getUsedSeats, - isAdminOrOwner, - type Member, -} from '@/lib/workspaces/organization' +import { generateSlug, isAdminOrOwner, type Member } from '@/lib/workspaces/organization' import { MemberInvitationCard, NoOrganizationView, @@ -93,6 +88,7 @@ export function TeamManagement() { memberName: string shouldReduceSeats: boolean isSelfRemoval?: boolean + isExternalRemoval?: boolean }>({ open: false, memberId: '', memberName: '', shouldReduceSeats: false }) const [transferDialogOpen, setTransferDialogOpen] = useState(false) const [transferPortalError, setTransferPortalError] = useState(null) @@ -108,8 +104,9 @@ export function TeamManagement() { ) const adminOrOwner = isAdminOrOwner(organization, session?.user?.email) - const usedSeats = getUsedSeats(organization) const totalSeats = organizationBillingData?.data?.totalSeats ?? 0 + const usedSeats = organizationBillingData?.data?.usedSeats ?? 0 + const canReduceSubscriptionSeats = Boolean(subscriptionData && checkTeamPlan(subscriptionData)) useEffect(() => { if ((hasTeamPlan || hasEnterprisePlan) && session?.user?.name && !orgName) { @@ -209,6 +206,7 @@ export function TeamManagement() { memberName: displayName, shouldReduceSeats: false, isSelfRemoval: isLeavingSelf, + isExternalRemoval: member.role === 'external', }) }, [session?.user, activeOrganization?.id] @@ -225,11 +223,13 @@ export function TeamManagement() { orgId: activeOrganization?.id, shouldReduceSeats, }) + setRemoveMemberDialog({ open: false, memberId: '', memberName: '', shouldReduceSeats: false, + isExternalRemoval: false, }) if (isSelfRemoval) { @@ -446,7 +446,7 @@ export function TeamManagement() { subscriptionData={subscriptionData || null} isLoadingSubscription={isLoadingSubscription} totalSeats={totalSeats} - usedSeats={usedSeats.used} + usedSeats={usedSeats} isLoading={isLoading} onAddSeatDialog={handleAddSeatDialog} /> @@ -466,7 +466,7 @@ export function TeamManagement() { onLoadUserWorkspaces={async () => {}} onWorkspaceToggle={handleWorkspaceToggle} inviteSuccess={inviteSuccess} - availableSeats={Math.max(0, totalSeats - usedSeats.used)} + availableSeats={Math.max(0, totalSeats - usedSeats)} maxSeats={totalSeats} invitationError={inviteMutation.error} isLoadingWorkspaces={isLoadingWorkspaces} @@ -504,7 +504,10 @@ export function TeamManagement() { open={removeMemberDialog.open} memberName={removeMemberDialog.memberName} shouldReduceSeats={removeMemberDialog.shouldReduceSeats} + canReduceSeats={canReduceSubscriptionSeats} isSelfRemoval={removeMemberDialog.isSelfRemoval} + isExternalRemoval={removeMemberDialog.isExternalRemoval} + isSubmitting={removeMemberMutation.isPending} error={removeMemberMutation.error} onOpenChange={(open: boolean) => { if (!open) setRemoveMemberDialog({ ...removeMemberDialog, open: false }) @@ -523,6 +526,7 @@ export function TeamManagement() { memberName: '', shouldReduceSeats: false, isSelfRemoval: false, + isExternalRemoval: false, }) } /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table.tsx index eed06f628a7..780ca6ebd15 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table.tsx @@ -89,6 +89,7 @@ export const PermissionsTable = ({ permissionType: changes.permissionType !== undefined ? changes.permissionType : permissionType, isCurrentUser: user.email === session?.user?.email, + isExternal: user.isExternal, } }) || [], [workspacePermissions?.users, existingUserPermissionChanges, session?.user?.email] @@ -212,6 +213,11 @@ export const PermissionsTable = ({ )} )} + {user.isExternal && ( + + External + + )} {hasChanges && ( Modified diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/types.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/types.ts index 7acb4db1583..d80b0afbc9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/types.ts @@ -8,5 +8,6 @@ export interface UserPermissions { permissionType: PermissionType isCurrentUser?: boolean isPendingInvitation?: boolean + isExternal?: boolean invitationId?: string } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx index 12869466262..b1c0268bbfa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { useParams, useRouter } from 'next/navigation' +import { useParams } from 'next/navigation' import { Button, type FileInputOptions, @@ -47,7 +47,6 @@ export function InviteModal({ inviteDisabledReason = null, organizationId = null, }: InviteModalProps) { - const router = useRouter() const formRef = useRef(null) const [emailItems, setEmailItems] = useState([]) const [userPermissions, setUserPermissions] = useState([]) @@ -103,9 +102,9 @@ export function InviteModal({ const isOutOfSeats = exceedsSeatCapacity || isAtSeatCapacity const seatLimitReason = hasSeatData ? availableSeats === 0 - ? `No available seats. Using ${usedSeats} of ${totalSeats}.` + ? `Internal invites may fail: using ${usedSeats} of ${totalSeats} seats. External workspace invites do not require seats.` : exceedsSeatCapacity - ? `Only ${availableSeats} seat${availableSeats === 1 ? '' : 's'} available.` + ? `Only ${availableSeats} internal seat${availableSeats === 1 ? '' : 's'} available. External workspace invites do not require seats.` : null : null @@ -235,7 +234,7 @@ export function InviteModal({ })) updatePermissionsMutation.mutate( - { workspaceId, updates }, + { workspaceId, organizationId: organizationId ?? undefined, updates }, { onSuccess: (data) => { if (data.users && data.total !== undefined) { @@ -253,6 +252,7 @@ export function InviteModal({ userPerms.canAdmin, hasPendingChanges, workspaceId, + organizationId, existingUserPermissionChanges, updatePermissions, updatePermissionsMutation, @@ -284,7 +284,7 @@ export function InviteModal({ } removeMember.mutate( - { userId: memberToRemove.userId, workspaceId }, + { userId: memberToRemove.userId, workspaceId, organizationId }, { onSuccess: () => { if (workspacePermissions) { @@ -318,6 +318,7 @@ export function InviteModal({ workspacePermissions, updatePermissions, removeMember, + organizationId, ]) const handleRemoveMemberCancel = useCallback(() => { @@ -334,7 +335,7 @@ export function InviteModal({ setErrorMessage(null) cancelInvitation.mutate( - { invitationId: invitationToRemove.invitationId, workspaceId }, + { invitationId: invitationToRemove.invitationId, workspaceId, organizationId }, { onSuccess: () => { setInvitationToRemove(null) @@ -346,7 +347,7 @@ export function InviteModal({ }, } ) - }, [invitationToRemove, workspaceId, userPerms.canAdmin, cancelInvitation]) + }, [invitationToRemove, workspaceId, userPerms.canAdmin, cancelInvitation, organizationId]) const handleRemoveInvitationCancel = useCallback(() => { setInvitationToRemove(null) @@ -421,23 +422,12 @@ export function InviteModal({ [workspaceId, userPerms.canAdmin, resendCooldowns, resendingInvitationIds, resendInvitation] ) - const handleUpgradeRedirect = useCallback(() => { - if (!workspaceId) return - onOpenChange(false) - router.push(`/workspace/${workspaceId}/settings/subscription`) - }, [onOpenChange, router, workspaceId]) - const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault() setErrorMessage(null) - if (isOutOfSeats) { - handleUpgradeRedirect() - return - } - if (!canInviteMembers || validEmails.length === 0 || !workspaceId) { return } @@ -451,7 +441,7 @@ export function InviteModal({ }) batchSendInvitations.mutate( - { workspaceId, invitations }, + { workspaceId, organizationId, invitations }, { onSuccess: (result) => { if (result.failed.length > 0) { @@ -474,10 +464,9 @@ export function InviteModal({ }, [ canInviteMembers, - isOutOfSeats, - handleUpgradeRedirect, validEmails, workspaceId, + organizationId, userPermissions, batchSendInvitations, ] @@ -504,6 +493,7 @@ export function InviteModal({ email: inv.email, permissionType: inv.permissionType, isPendingInvitation: true, + isExternal: inv.isExternal, invitationId: inv.invitationId, })), [pendingInvitations] @@ -641,10 +631,6 @@ export function InviteModal({ type='button' variant='primary' onClick={() => { - if (isOutOfSeats) { - handleUpgradeRedirect() - return - } formRef.current?.requestSubmit() }} disabled={ @@ -653,7 +639,7 @@ export function InviteModal({ isSubmitting || isSaving || !workspaceId || - (!isOutOfSeats && !hasNewInvites) + !hasNewInvites } className='ml-auto' > @@ -663,9 +649,7 @@ export function InviteModal({ ? 'Admin Access Required' : isSubmitting ? 'Inviting...' - : isOutOfSeats - ? 'Upgrade to invite' - : 'Invite'} + : 'Invite'} diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 24778e0c014..7561ae9713a 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -1,11 +1,8 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { workspaceCredentialKeys } from '@/hooks/queries/credentials' import { organizationKeys } from '@/hooks/queries/organization' import { workspaceKeys } from './workspace' -/** - * Query key factory for invitation-related queries. - * Provides hierarchical cache keys for workspace invitations. - */ export const invitationKeys = { all: ['invitations'] as const, lists: () => [...invitationKeys.all, 'list'] as const, @@ -17,6 +14,7 @@ export interface PendingInvitationRow { workspaceId: string email: string permission: 'admin' | 'write' | 'read' + membershipIntent?: 'internal' | 'external' status: string createdAt: string } @@ -25,6 +23,7 @@ export interface WorkspaceInvitation { email: string permissionType: 'admin' | 'write' | 'read' isPendingInvitation: boolean + isExternal: boolean invitationId?: string } @@ -49,6 +48,7 @@ async function fetchPendingInvitations( email: inv.email, permissionType: inv.permission, isPendingInvitation: true, + isExternal: inv.membershipIntent === 'external', invitationId: inv.id, })) || [] ) @@ -70,6 +70,7 @@ export function usePendingInvitations(workspaceId: string | undefined) { interface BatchSendInvitationsParams { workspaceId: string + organizationId?: string | null invitations: Array<{ email: string; permission: 'admin' | 'write' | 'read' }> } @@ -79,7 +80,7 @@ interface BatchInvitationResult { } /** - * Sends multiple workspace invitations in parallel. + * Sends workspace invitations through the server-side batch endpoint. * Returns results for each invitation indicating success or failure. */ export function useBatchSendWorkspaceInvitations() { @@ -90,45 +91,38 @@ export function useBatchSendWorkspaceInvitations() { workspaceId, invitations, }: BatchSendInvitationsParams): Promise => { - const results = await Promise.allSettled( - invitations.map(async ({ email, permission }) => { - const response = await fetch('/api/workspaces/invitations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workspaceId, - email, - permission, - }), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to send invitation') - } - - return { email, data: await response.json() } - }) - ) + const response = await fetch('/api/workspaces/invitations/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workspaceId, + invitations, + }), + }) - const successful: string[] = [] - const failed: Array<{ email: string; error: string }> = [] + const result = await response.json() - results.forEach((result, index) => { - const email = invitations[index].email - if (result.status === 'fulfilled') { - successful.push(email) - } else { - failed.push({ email, error: result.reason?.message || 'Unknown error' }) - } - }) + if (!response.ok) { + throw new Error(result.error || 'Failed to send invitations') + } - return { successful, failed } + return { + successful: result.successful ?? [], + failed: result.failed ?? [], + } }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: invitationKeys.list(variables.workspaceId), }) + if (variables.organizationId) { + queryClient.invalidateQueries({ + queryKey: organizationKeys.roster(variables.organizationId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.billing(variables.organizationId), + }) + } }, }) } @@ -136,6 +130,7 @@ export function useBatchSendWorkspaceInvitations() { interface CancelInvitationParams { invitationId: string workspaceId: string + organizationId?: string | null } /** @@ -159,10 +154,18 @@ export function useCancelWorkspaceInvitation() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: invitationKeys.list(variables.workspaceId), }) + if (variables.organizationId) { + queryClient.invalidateQueries({ + queryKey: organizationKeys.roster(variables.organizationId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.billing(variables.organizationId), + }) + } }, }) } @@ -204,6 +207,7 @@ export function useResendWorkspaceInvitation() { interface RemoveMemberParams { userId: string workspaceId: string + organizationId?: string | null } /** @@ -232,6 +236,17 @@ export function useRemoveWorkspaceMember() { queryClient.invalidateQueries({ queryKey: workspaceKeys.permissions(variables.workspaceId), }) + queryClient.invalidateQueries({ + queryKey: workspaceKeys.members(variables.workspaceId), + }) + queryClient.invalidateQueries({ + queryKey: workspaceCredentialKeys.all, + }) + if (variables.organizationId) { + queryClient.invalidateQueries({ + queryKey: organizationKeys.roster(variables.organizationId), + }) + } }, }) } diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index 859d1435807..b55bf9ffe50 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -3,10 +3,12 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta import { client } from '@/lib/auth/auth-client' import { isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers' import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils' +import { workspaceCredentialKeys } from '@/hooks/queries/credentials' import { subscriptionKeys } from '@/hooks/queries/subscription' import { workspaceKeys } from '@/hooks/queries/workspace' const logger = createLogger('OrganizationQueries') +const invitationListsKey = ['invitations', 'list'] as const /** * Query key factories for organization-related queries @@ -33,7 +35,7 @@ export type RosterWorkspaceAccess = { export type RosterMember = { memberId: string userId: string - role: string + role: 'owner' | 'admin' | 'member' | 'external' createdAt: string name: string email: string @@ -46,6 +48,7 @@ export type RosterPendingInvitation = { email: string role: string kind: 'organization' | 'workspace' + membershipIntent?: 'internal' | 'external' createdAt: string expiresAt: string inviteeName: string | null @@ -401,6 +404,9 @@ export function useRemoveMember() { queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) + queryClient.invalidateQueries({ queryKey: workspaceKeys.all }) + queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.all }) + queryClient.invalidateQueries({ queryKey: invitationListsKey }) }, }) } @@ -531,7 +537,9 @@ export function useCancelInvitation() { onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) + queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) + queryClient.invalidateQueries({ queryKey: invitationListsKey }) }, }) } diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index 16e1ea8d3c5..6fcc3e16f92 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -251,6 +251,7 @@ export interface WorkspaceUser { name: string | null image: string | null permissionType: 'admin' | 'write' | 'read' + isExternal: boolean } /** Viewer context for a workspace permissions response. */ diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index f23f38a3d6e..9af1526ac30 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { member, organization, user, userStats } from '@sim/db/schema' +import { invitation, member, organization, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, count, eq, gt, ne } from 'drizzle-orm' import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing' import { @@ -172,6 +172,19 @@ export async function getOrganizationBillingData( const averageUsagePerMember = members.length > 0 ? totalCurrentUsage / members.length : 0 + const [pendingInvitationCount] = 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 usedSeats = members.length + (pendingInvitationCount?.count ?? 0) + const billingPeriodStart = subscription.periodStart || null const billingPeriodEnd = subscription.periodEnd || null @@ -181,7 +194,7 @@ export async function getOrganizationBillingData( subscriptionPlan: subscription.plan, subscriptionStatus: subscription.status || 'inactive', totalSeats: effectiveSeats, - usedSeats: members.length, + usedSeats, seatsCount: licensedSeats, totalCurrentUsage: roundCurrency(totalCurrentUsage), totalUsageLimit: roundCurrency(totalUsageLimit), diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index ff96c5bd9f5..d5536da79bb 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -7,8 +7,12 @@ import { db } from '@sim/db' import { + credential, + credentialMember, + invitation, member, organization, + permissionGroupMember, permissions, subscription as subscriptionTable, user, @@ -25,7 +29,7 @@ import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' -import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access' +import type { DbOrTx } from '@/lib/db/types' const logger = createLogger('OrganizationMembership') @@ -233,6 +237,7 @@ export interface AddMemberResult { success: boolean memberId?: string error?: string + failureCode?: MembershipAdditionFailureCode billingActions: { proUsageSnapshotted: boolean /** @@ -265,12 +270,200 @@ export interface RemoveMemberResult { proRestored: boolean usageRestored: boolean workspaceAccessRevoked: number + pendingInvitationsCancelled: number } } +export interface RemoveExternalWorkspaceAccessResult { + success: boolean + error?: string + workspaceAccessRevoked: number + permissionGroupsRevoked: number + credentialMembershipsRevoked: number + pendingInvitationsCancelled: number +} + +export type MembershipAdditionFailureCode = + | 'user-not-found' + | 'organization-not-found' + | 'already-member' + | 'already-in-other-organization' + | 'no-seats-available' + +async function reassignOwnedOrganizationWorkspacesTx({ + tx, + userId, + organizationId, + workspaceIds, +}: { + tx: DbOrTx + userId: string + organizationId: string + workspaceIds: string[] +}) { + const [ownerMembership] = await tx + .select({ userId: member.userId }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner'))) + .limit(1) + + const ownerId = ownerMembership?.userId + if (!ownerId || ownerId === userId || workspaceIds.length === 0) return 0 + + const reassignedWorkspaces = await tx + .update(workspace) + .set({ ownerId, updatedAt: new Date() }) + .where( + and( + eq(workspace.organizationId, organizationId), + eq(workspace.ownerId, userId), + inArray(workspace.id, workspaceIds) + ) + ) + .returning({ id: workspace.id }) + + if (reassignedWorkspaces.length === 0) return 0 + + const now = new Date() + await tx + .update(permissions) + .set({ permissionType: 'admin', updatedAt: now }) + .where( + and( + eq(permissions.userId, ownerId), + eq(permissions.entityType, 'workspace'), + inArray( + permissions.entityId, + reassignedWorkspaces.map((row) => row.id) + ) + ) + ) + + await tx + .insert(permissions) + .values( + reassignedWorkspaces.map((row) => ({ + id: generateId(), + userId: ownerId, + entityType: 'workspace', + entityId: row.id, + permissionType: 'admin' as const, + createdAt: now, + updatedAt: now, + })) + ) + .onConflictDoNothing() + + return reassignedWorkspaces.length +} + +async function revokeWorkspaceCredentialMembershipsTx({ + tx, + workspaceIds, + userId, +}: { + tx: DbOrTx + workspaceIds: string[] + userId: string +}) { + if (workspaceIds.length === 0) return 0 + + const workspaceCredentialRows = await tx + .select({ + credentialId: credential.id, + workspaceId: credential.workspaceId, + ownerId: workspace.ownerId, + }) + .from(credential) + .innerJoin(workspace, eq(credential.workspaceId, workspace.id)) + .where(inArray(credential.workspaceId, workspaceIds)) + + if (workspaceCredentialRows.length === 0) return 0 + + const credentialIds = workspaceCredentialRows.map((row) => row.credentialId) + const ownerByCredentialId = new Map( + workspaceCredentialRows.map((row) => [row.credentialId, row.ownerId]) + ) + + const userAdminMemberships = await tx + .select({ credentialId: credentialMember.credentialId }) + .from(credentialMember) + .where( + and( + eq(credentialMember.userId, userId), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active'), + inArray(credentialMember.credentialId, credentialIds) + ) + ) + + for (const { credentialId } of userAdminMemberships) { + const ownerId = ownerByCredentialId.get(credentialId) + if (!ownerId || ownerId === userId) continue + + const otherAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, credentialId), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active'), + ne(credentialMember.userId, userId) + ) + ) + .limit(1) + + if (otherAdmins.length > 0) continue + + const now = new Date() + const [existingOwnerMembership] = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, ownerId)) + ) + .limit(1) + + if (existingOwnerMembership) { + await tx + .update(credentialMember) + .set({ role: 'admin', status: 'active', updatedAt: now }) + .where(eq(credentialMember.id, existingOwnerMembership.id)) + } else { + await tx.insert(credentialMember).values({ + id: generateId(), + credentialId, + userId: ownerId, + role: 'admin', + status: 'active', + joinedAt: now, + invitedBy: ownerId, + createdAt: now, + updatedAt: now, + }) + } + } + + const revokedMemberships = await tx + .update(credentialMember) + .set({ status: 'revoked', updatedAt: new Date() }) + .where( + and( + eq(credentialMember.userId, userId), + eq(credentialMember.status, 'active'), + inArray(credentialMember.credentialId, credentialIds) + ) + ) + .returning({ credentialId: credentialMember.credentialId }) + + return revokedMemberships.length +} + export interface MembershipValidationResult { canAdd: boolean reason?: string + failureCode?: MembershipAdditionFailureCode existingOrgId?: string seatValidation?: { currentSeats: number @@ -301,6 +494,7 @@ export async function ensureUserInOrganization( success: false, alreadyMember: false, existingOrgId: existingMembership.organizationId, + failureCode: 'already-in-other-organization', error: 'User is already a member of another organization. Users can only belong to one organization at a time.', billingActions: { @@ -330,7 +524,7 @@ export async function validateMembershipAddition( const [userData] = await db.select({ id: user.id }).from(user).where(eq(user.id, userId)).limit(1) if (!userData) { - return { canAdd: false, reason: 'User not found' } + return { canAdd: false, reason: 'User not found', failureCode: 'user-not-found' } } const [orgData] = await db @@ -340,7 +534,11 @@ export async function validateMembershipAddition( .limit(1) if (!orgData) { - return { canAdd: false, reason: 'Organization not found' } + return { + canAdd: false, + reason: 'Organization not found', + failureCode: 'organization-not-found', + } } const existingMemberships = await db @@ -354,13 +552,18 @@ export async function validateMembershipAddition( ) if (isAlreadyMemberOfThisOrg) { - return { canAdd: false, reason: 'User is already a member of this organization' } + return { + canAdd: false, + reason: 'User is already a member of this organization', + failureCode: 'already-member', + } } return { canAdd: false, reason: 'User is already a member of another organization. Users can only belong to one organization at a time.', + failureCode: 'already-in-other-organization', existingOrgId: existingMemberships[0].organizationId, } } @@ -372,6 +575,7 @@ export async function validateMembershipAddition( return { canAdd: false, reason: seatValidation.reason || 'No seats available', + failureCode: 'no-seats-available', seatValidation: { currentSeats: seatValidation.currentSeats, maxSeats: seatValidation.maxSeats, @@ -573,7 +777,12 @@ export async function addUserToOrganization(params: AddMemberParams): Promise { + const { + workspaceIdsToRevoke, + usageCaptured, + credentialMembershipsRevoked, + pendingInvitationsCancelled, + } = await db.transaction(async (tx) => { const deletedMember = await tx .delete(member) .where(and(eq(member.id, memberId), ne(member.role, 'owner'))) @@ -737,22 +954,49 @@ export async function removeUserFromOrganization( } } + const [targetUser] = await tx + .select({ email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + const cancelledInvitations = targetUser?.email + ? await tx + .update(invitation) + .set({ status: 'cancelled', updatedAt: new Date() }) + .where( + and( + eq(invitation.organizationId, organizationId), + eq(invitation.status, 'pending'), + sql`lower(${invitation.email}) = lower(${targetUser.email})` + ) + ) + .returning({ id: invitation.id }) + : [] + const orgWorkspaces = await tx .select({ id: workspace.id }) .from(workspace) - .where( - and( - eq(workspace.organizationId, organizationId), - eq(workspace.workspaceMode, 'organization') - ) - ) + .where(eq(workspace.organizationId, organizationId)) if (orgWorkspaces.length === 0) { - return { workspaceIdsToRevoke: [] as string[], usageCaptured: capturedUsage } + return { + workspaceIdsToRevoke: [] as string[], + usageCaptured: capturedUsage, + credentialMembershipsRevoked: 0, + pendingInvitationsCancelled: cancelledInvitations.length, + } } const workspaceIds = orgWorkspaces.map((w) => w.id) + await reassignOwnedOrganizationWorkspacesTx({ + tx, + userId, + organizationId, + workspaceIds, + }) + const deletedPerms = await tx .delete(permissions) .where( @@ -764,14 +1008,32 @@ export async function removeUserFromOrganization( ) .returning({ entityId: permissions.entityId }) + await tx + .delete(permissionGroupMember) + .where( + and( + eq(permissionGroupMember.userId, userId), + inArray(permissionGroupMember.workspaceId, workspaceIds) + ) + ) + + const credentialMembershipsRevoked = await revokeWorkspaceCredentialMembershipsTx({ + tx, + workspaceIds, + userId, + }) + return { workspaceIdsToRevoke: deletedPerms.map((row) => row.entityId), usageCaptured: capturedUsage, + credentialMembershipsRevoked, + pendingInvitationsCancelled: cancelledInvitations.length, } }) billingActions.usageCaptured = usageCaptured billingActions.workspaceAccessRevoked = workspaceIdsToRevoke.length + billingActions.pendingInvitationsCancelled = pendingInvitationsCancelled if (usageCaptured > 0) { logger.info('Captured departed member usage', { @@ -786,21 +1048,10 @@ export async function removeUserFromOrganization( userId, memberId, workspaceAccessRevoked: workspaceIdsToRevoke.length, + credentialMembershipsRevoked, + pendingInvitationsCancelled, }) - for (const workspaceId of workspaceIdsToRevoke) { - try { - await revokeWorkspaceCredentialMemberships(workspaceId, userId) - } catch (credentialError) { - logger.error('Failed to revoke workspace credential memberships on org leave', { - organizationId, - userId, - workspaceId, - error: credentialError, - }) - } - } - if (!skipBillingLogic) { try { const remainingPaidTeams = await db @@ -852,6 +1103,167 @@ export async function removeUserFromOrganization( } } +/** + * Removes a non-member's access from every workspace owned by an organization. + * External workspace members have workspace permissions but no organization member row. + */ +export async function removeExternalUserFromOrganizationWorkspaces(params: { + userId: string + organizationId: string +}): Promise { + const { userId, organizationId } = params + + try { + const [existingMember] = await db + .select({ id: member.id }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) + .limit(1) + + if (existingMember) { + return { + success: false, + error: 'User is an organization member', + workspaceAccessRevoked: 0, + permissionGroupsRevoked: 0, + credentialMembershipsRevoked: 0, + pendingInvitationsCancelled: 0, + } + } + + const { + workspaceAccessRevoked, + permissionGroupsRevoked, + credentialMembershipsRevoked, + pendingInvitationsCancelled, + } = await db.transaction(async (tx) => { + const orgWorkspaces = await tx + .select({ id: workspace.id }) + .from(workspace) + .where(eq(workspace.organizationId, organizationId)) + + if (orgWorkspaces.length === 0) { + return { + workspaceAccessRevoked: 0, + permissionGroupsRevoked: 0, + credentialMembershipsRevoked: 0, + pendingInvitationsCancelled: 0, + } + } + + const workspaceIds = orgWorkspaces.map((w) => w.id) + const [targetUser] = await tx + .select({ email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + await reassignOwnedOrganizationWorkspacesTx({ + tx, + userId, + organizationId, + workspaceIds, + }) + + const deletedPermissions = await tx + .delete(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + inArray(permissions.entityId, workspaceIds) + ) + ) + .returning({ entityId: permissions.entityId }) + + const deletedPermissionGroups = await tx + .delete(permissionGroupMember) + .where( + and( + eq(permissionGroupMember.userId, userId), + inArray(permissionGroupMember.workspaceId, workspaceIds) + ) + ) + .returning({ id: permissionGroupMember.id }) + + const credentialMembershipsRevoked = await revokeWorkspaceCredentialMembershipsTx({ + tx, + workspaceIds, + userId, + }) + + const cancelledInvitations = targetUser?.email + ? await tx + .update(invitation) + .set({ status: 'cancelled', updatedAt: new Date() }) + .where( + and( + eq(invitation.organizationId, organizationId), + eq(invitation.status, 'pending'), + eq(invitation.membershipIntent, 'external'), + sql`lower(${invitation.email}) = lower(${targetUser.email})` + ) + ) + .returning({ id: invitation.id }) + : [] + + return { + workspaceAccessRevoked: deletedPermissions.length, + permissionGroupsRevoked: deletedPermissionGroups.length, + credentialMembershipsRevoked, + pendingInvitationsCancelled: cancelledInvitations.length, + } + }) + + if ( + workspaceAccessRevoked === 0 && + permissionGroupsRevoked === 0 && + credentialMembershipsRevoked === 0 && + pendingInvitationsCancelled === 0 + ) { + return { + success: false, + error: 'External workspace member not found', + workspaceAccessRevoked, + permissionGroupsRevoked, + credentialMembershipsRevoked, + pendingInvitationsCancelled, + } + } + + logger.info('Removed external workspace member from organization workspaces', { + organizationId, + userId, + workspaceAccessRevoked, + permissionGroupsRevoked, + credentialMembershipsRevoked, + pendingInvitationsCancelled, + }) + + return { + success: true, + workspaceAccessRevoked, + permissionGroupsRevoked, + credentialMembershipsRevoked, + pendingInvitationsCancelled, + } + } catch (error) { + logger.error('Failed to remove external workspace member from organization workspaces', { + organizationId, + userId, + error, + }) + return { + success: false, + error: 'Failed to remove external workspace member', + workspaceAccessRevoked: 0, + permissionGroupsRevoked: 0, + credentialMembershipsRevoked: 0, + pendingInvitationsCancelled: 0, + } + } +} + export interface TransferOwnershipParams { organizationId: string currentOwnerUserId: string @@ -1044,6 +1456,7 @@ export async function transferOrganizationOwnership( const [orgSub] = await tx .select({ + id: subscriptionTable.id, stripeCustomerId: subscriptionTable.stripeCustomerId, }) .from(subscriptionTable) @@ -1056,20 +1469,10 @@ export async function transferOrganizationOwnership( .limit(1) if (orgSub?.stripeCustomerId) { - const [newOwnerUser] = await tx - .select({ email: user.email, name: user.name }) - .from(user) - .where(eq(user.id, newOwnerUserId)) - .limit(1) - - if (newOwnerUser?.email) { - await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CUSTOMER_CONTACT, { - stripeCustomerId: orgSub.stripeCustomerId, - email: newOwnerUser.email, - name: newOwnerUser.name ?? undefined, - reason: 'ownership-transfer', - }) - } + await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CUSTOMER_CONTACT, { + subscriptionId: orgSub.id, + reason: 'ownership-transfer', + }) } }) diff --git a/apps/sim/lib/billing/organizations/seats.ts b/apps/sim/lib/billing/organizations/seats.ts new file mode 100644 index 00000000000..0cf5ff86129 --- /dev/null +++ b/apps/sim/lib/billing/organizations/seats.ts @@ -0,0 +1,130 @@ +import { db } from '@sim/db' +import { invitation, member, subscription } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, count, eq, gt, inArray, ne } from 'drizzle-orm' +import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' +import { isTeam } from '@/lib/billing/plan-helpers' +import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' + +const logger = createLogger('OrganizationSeats') + +export interface ReduceOrganizationSeatsResult { + reduced: boolean + previousSeats?: number + seats?: number + reason?: string + outboxEventId?: string +} + +interface ReduceOrganizationSeatsByOneParams { + organizationId: string + actorUserId: string + removedUserId: string +} + +export async function reduceOrganizationSeatsByOne({ + organizationId, + actorUserId, + removedUserId, +}: ReduceOrganizationSeatsByOneParams): Promise { + if (!isBillingEnabled) { + return { reduced: false, reason: 'Billing is not enabled' } + } + + return db.transaction(async (tx) => { + const [orgSubscription] = await tx + .select() + .from(subscription) + .where( + and( + eq(subscription.referenceId, organizationId), + inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) + ) + ) + .for('update') + .limit(1) + + if (!orgSubscription) { + return { reduced: false, reason: 'No active subscription found' } + } + + if (await isOrganizationBillingBlocked(organizationId)) { + return { reduced: false, reason: 'An active subscription is required' } + } + + if (!isTeam(orgSubscription.plan)) { + return { reduced: false, reason: 'Seat changes are only available for Team plans' } + } + + if (!orgSubscription.stripeSubscriptionId) { + return { reduced: false, reason: 'No Stripe subscription found for this organization' } + } + + const currentSeats = orgSubscription.seats || 1 + if (currentSeats <= 1) { + return { + reduced: false, + previousSeats: currentSeats, + seats: currentSeats, + reason: 'Minimum 1 seat required', + } + } + + const [memberCountRow] = await tx + .select({ count: count() }) + .from(member) + .where(eq(member.organizationId, organizationId)) + + const [pendingCountRow] = await tx + .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 occupiedSeats = (memberCountRow?.count ?? 0) + (pendingCountRow?.count ?? 0) + const nextSeats = currentSeats - 1 + + if (nextSeats < occupiedSeats) { + return { + reduced: false, + previousSeats: currentSeats, + seats: currentSeats, + reason: `Cannot reduce seats below current occupancy (${occupiedSeats}).`, + } + } + + await tx + .update(subscription) + .set({ seats: nextSeats }) + .where(eq(subscription.id, orgSubscription.id)) + + const outboxEventId = await enqueueOutboxEvent( + tx, + OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS, + { + subscriptionId: orgSubscription.id, + reason: 'member-removed-seat-reduction', + } + ) + + logger.info('Reduced organization seats after member removal', { + organizationId, + actorUserId, + removedUserId, + previousSeats: currentSeats, + seats: nextSeats, + outboxEventId, + }) + + return { reduced: true, previousSeats: currentSeats, seats: nextSeats, outboxEventId } + }) +} diff --git a/apps/sim/lib/billing/validation/seat-management.test.ts b/apps/sim/lib/billing/validation/seat-management.test.ts index 305cc401b14..02b7dc740ea 100644 --- a/apps/sim/lib/billing/validation/seat-management.test.ts +++ b/apps/sim/lib/billing/validation/seat-management.test.ts @@ -35,7 +35,10 @@ vi.mock('@/lib/messaging/email/validation', () => ({ quickValidateEmail: vi.fn((email: string) => ({ isValid: email.includes('@') })), })) -import { getOrganizationSeatInfo } from '@/lib/billing/validation/seat-management' +import { + getOrganizationSeatInfo, + validateSeatAvailability, +} from '@/lib/billing/validation/seat-management' /** * Queues the next N responses for `db.select().from(...).where(...)` calls, @@ -82,3 +85,30 @@ describe('getOrganizationSeatInfo', () => { expect(mockGetOrganizationSubscription).not.toHaveBeenCalled() }) }) + +describe('validateSeatAvailability', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockFeatureFlags.isBillingEnabled = true + mockGetOrganizationSubscription.mockResolvedValue({ + id: 'sub-1', + plan: 'team', + status: 'active', + seats: 10, + }) + }) + + it('uses the internal pending invitation count when checking seats', async () => { + queueSelectResponses([[{ count: 2 }], [{ count: 1 }]]) + + const result = await validateSeatAvailability('org-1', 1) + + expect(result).toMatchObject({ + canInvite: true, + currentSeats: 3, + maxSeats: 10, + availableSeats: 7, + }) + }) +}) diff --git a/apps/sim/lib/billing/validation/seat-management.ts b/apps/sim/lib/billing/validation/seat-management.ts index f65a1900d4a..df9a83d8e72 100644 --- a/apps/sim/lib/billing/validation/seat-management.ts +++ b/apps/sim/lib/billing/validation/seat-management.ts @@ -82,6 +82,7 @@ export async function validateSeatAvailability( const pendingFilters = [ eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'), + ne(invitation.membershipIntent, 'external'), gt(invitation.expiresAt, new Date()), ] if (options.excludePendingInvitationId) { @@ -164,6 +165,7 @@ export async function getOrganizationSeatInfo( and( eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'), + ne(invitation.membershipIntent, 'external'), gt(invitation.expiresAt, new Date()) ) ) @@ -247,6 +249,7 @@ export async function validateBulkInvitations( and( eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'), + ne(invitation.membershipIntent, 'external'), gt(invitation.expiresAt, new Date()) ) ) diff --git a/apps/sim/lib/billing/webhooks/outbox-handlers.ts b/apps/sim/lib/billing/webhooks/outbox-handlers.ts index 4e499869266..4a560383e25 100644 --- a/apps/sim/lib/billing/webhooks/outbox-handlers.ts +++ b/apps/sim/lib/billing/webhooks/outbox-handlers.ts @@ -1,9 +1,11 @@ import { db } from '@sim/db' -import { subscription as subscriptionTable } from '@sim/db/schema' +import { member, subscription as subscriptionTable, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' +import { isTeam } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' import { resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method' +import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import type { OutboxHandler } from '@/lib/core/outbox/service' const logger = createLogger('BillingOutboxHandlers') @@ -17,6 +19,7 @@ export const OUTBOX_EVENT_TYPES = { * enqueue this event after every DB change to `cancelAtPeriodEnd`. */ STRIPE_SYNC_CANCEL_AT_PERIOD_END: 'stripe.sync-cancel-at-period-end', + STRIPE_SYNC_SUBSCRIPTION_SEATS: 'stripe.sync-subscription-seats', STRIPE_THRESHOLD_OVERAGE_INVOICE: 'stripe.threshold-overage-invoice', STRIPE_SYNC_CUSTOMER_CONTACT: 'stripe.sync-customer-contact', } as const @@ -29,10 +32,15 @@ export interface StripeSyncCancelAtPeriodEndPayload { reason?: string } +export interface StripeSyncSubscriptionSeatsPayload { + /** The DB subscription row id — the handler reads current seats from this row. */ + subscriptionId: string + reason?: string +} + export interface StripeSyncCustomerContactPayload { - stripeCustomerId: string - email: string - name?: string + /** The DB subscription row id — handler resolves current owner/contact at processing time. */ + subscriptionId: string reason?: string } @@ -49,6 +57,21 @@ export interface StripeThresholdOverageInvoicePayload { metadata?: Record } +async function getSubscriptionSeatSyncState(subscriptionId: string) { + const [row] = await db + .select({ + plan: subscriptionTable.plan, + seats: subscriptionTable.seats, + status: subscriptionTable.status, + stripeSubscriptionId: subscriptionTable.stripeSubscriptionId, + }) + .from(subscriptionTable) + .where(eq(subscriptionTable.id, subscriptionId)) + .limit(1) + + return row ?? null +} + const stripeSyncCancelAtPeriodEnd: OutboxHandler = async ( payload, ctx @@ -86,6 +109,113 @@ const stripeSyncCancelAtPeriodEnd: OutboxHandler = async ( + payload, + ctx +) => { + const stripe = requireStripeClient() + const maxSyncAttempts = 2 + + for (let attempt = 1; attempt <= maxSyncAttempts; attempt++) { + const row = await getSubscriptionSeatSyncState(payload.subscriptionId) + if (!row) { + logger.warn('Subscription not found when syncing seats', { + eventId: ctx.eventId, + subscriptionId: payload.subscriptionId, + }) + return + } + + if (!isTeam(row.plan)) { + logger.info('Skipping seat sync for non-Team subscription', { + eventId: ctx.eventId, + subscriptionId: payload.subscriptionId, + plan: row.plan, + }) + return + } + + if (!row.stripeSubscriptionId) { + logger.warn('Subscription has no Stripe id when syncing seats', { + eventId: ctx.eventId, + subscriptionId: payload.subscriptionId, + }) + return + } + + if (!hasUsableSubscriptionStatus(row.status)) { + logger.warn('Skipping seat sync for unusable DB subscription status', { + eventId: ctx.eventId, + subscriptionId: payload.subscriptionId, + status: row.status, + }) + return + } + + const desiredSeats = row.seats || 1 + const stripeSubscription = await stripe.subscriptions.retrieve(row.stripeSubscriptionId) + + if (!hasUsableSubscriptionStatus(stripeSubscription.status)) { + logger.warn('Skipping seat sync for unusable Stripe subscription', { + eventId: ctx.eventId, + subscriptionId: payload.subscriptionId, + stripeSubscriptionId: row.stripeSubscriptionId, + stripeStatus: stripeSubscription.status, + }) + return + } + + const subscriptionItem = stripeSubscription.items.data[0] + if (!subscriptionItem) { + throw new Error( + `No subscription item found for Stripe subscription ${row.stripeSubscriptionId}` + ) + } + + if (subscriptionItem.quantity !== desiredSeats) { + await stripe.subscriptions.update( + row.stripeSubscriptionId, + { + items: [ + { + id: subscriptionItem.id, + quantity: desiredSeats, + }, + ], + proration_behavior: 'always_invoice', + }, + { idempotencyKey: `outbox:${ctx.eventId}:seats:${desiredSeats}` } + ) + } + + const latest = await getSubscriptionSeatSyncState(payload.subscriptionId) + const latestSeats = latest?.seats || 1 + if (latestSeats !== desiredSeats) { + logger.info('Subscription seats changed during Stripe sync; retrying latest value', { + eventId: ctx.eventId, + subscriptionId: payload.subscriptionId, + stripeSubscriptionId: row.stripeSubscriptionId, + attemptedSeats: desiredSeats, + latestSeats, + attempt, + }) + continue + } + + logger.info('Synced subscription seats from DB to Stripe', { + eventId: ctx.eventId, + subscriptionId: payload.subscriptionId, + stripeSubscriptionId: row.stripeSubscriptionId, + seats: desiredSeats, + alreadySynced: subscriptionItem.quantity === desiredSeats, + reason: payload.reason, + }) + return + } + + throw new Error(`Subscription seats changed while syncing ${payload.subscriptionId}`) +} + const stripeThresholdOverageInvoice: OutboxHandler = async ( payload, ctx @@ -178,18 +308,63 @@ const stripeSyncCustomerContact: OutboxHandler payload, ctx ) => { + const [subscriptionRow] = await db + .select({ + referenceId: subscriptionTable.referenceId, + stripeCustomerId: subscriptionTable.stripeCustomerId, + }) + .from(subscriptionTable) + .where(eq(subscriptionTable.id, payload.subscriptionId)) + .limit(1) + + if (!subscriptionRow) { + logger.warn('Subscription not found when syncing Stripe customer contact', { + eventId: ctx.eventId, + subscriptionId: payload.subscriptionId, + }) + return + } + + if (!subscriptionRow.stripeCustomerId) { + logger.warn('Subscription has no Stripe customer id when syncing contact', { + eventId: ctx.eventId, + subscriptionId: payload.subscriptionId, + }) + return + } + + const [owner] = await db + .select({ + email: user.email, + name: user.name, + }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(and(eq(member.organizationId, subscriptionRow.referenceId), eq(member.role, 'owner'))) + .limit(1) + + if (!owner) { + logger.warn('Organization owner not found when syncing Stripe customer contact', { + eventId: ctx.eventId, + subscriptionId: payload.subscriptionId, + organizationId: subscriptionRow.referenceId, + }) + return + } + const stripe = requireStripeClient() await stripe.customers.update( - payload.stripeCustomerId, + subscriptionRow.stripeCustomerId, { - email: payload.email, - ...(payload.name ? { name: payload.name } : {}), + email: owner.email, + ...(owner.name ? { name: owner.name } : {}), }, { idempotencyKey: `outbox:${ctx.eventId}` } ) logger.info('Synced Stripe customer contact', { eventId: ctx.eventId, - stripeCustomerId: payload.stripeCustomerId, + stripeCustomerId: subscriptionRow.stripeCustomerId, + subscriptionId: payload.subscriptionId, reason: payload.reason, }) } @@ -197,6 +372,8 @@ const stripeSyncCustomerContact: OutboxHandler export const billingOutboxHandlers = { [OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END]: stripeSyncCancelAtPeriodEnd as OutboxHandler, + [OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS]: + stripeSyncSubscriptionSeats as OutboxHandler, [OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE]: stripeThresholdOverageInvoice as OutboxHandler, [OUTBOX_EVENT_TYPES.STRIPE_SYNC_CUSTOMER_CONTACT]: diff --git a/apps/sim/lib/core/telemetry.ts b/apps/sim/lib/core/telemetry.ts index 016de4b7614..9dd1f409580 100644 --- a/apps/sim/lib/core/telemetry.ts +++ b/apps/sim/lib/core/telemetry.ts @@ -540,11 +540,13 @@ export const PlatformEvents = { invitedBy: string inviteeEmail: string role: string + membershipIntent?: string }) => { trackPlatformEvent('platform.workspace.member_invited', { 'workspace.id': attrs.workspaceId, 'user.id': attrs.invitedBy, 'invitation.role': attrs.role, + ...(attrs.membershipIntent ? { 'invitation.membership_intent': attrs.membershipIntent } : {}), }) }, diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 490fb35c2c4..0593160b739 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -3,6 +3,7 @@ import { credential, credentialMember, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray, ne } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CredentialAccess') @@ -74,7 +75,15 @@ export async function revokeWorkspaceCredentialMemberships( workspaceId: string, userId: string ): Promise { - const workspaceCredentialIds = await db + await revokeWorkspaceCredentialMembershipsTx(db, workspaceId, userId) +} + +export async function revokeWorkspaceCredentialMembershipsTx( + tx: DbOrTx, + workspaceId: string, + userId: string +): Promise { + const workspaceCredentialIds = await tx .select({ id: credential.id }) .from(credential) .where(eq(credential.workspaceId, workspaceId)) @@ -83,7 +92,7 @@ export async function revokeWorkspaceCredentialMemberships( const credIds = workspaceCredentialIds.map((c) => c.id) - const [workspaceRow] = await db + const [workspaceRow] = await tx .select({ ownerId: workspace.ownerId }) .from(workspace) .where(eq(workspace.id, workspaceId)) @@ -92,7 +101,7 @@ export async function revokeWorkspaceCredentialMemberships( const ownerId = workspaceRow?.ownerId if (ownerId && ownerId !== userId) { - const userAdminMemberships = await db + const userAdminMemberships = await tx .select({ credentialId: credentialMember.credentialId }) .from(credentialMember) .where( @@ -105,7 +114,7 @@ export async function revokeWorkspaceCredentialMemberships( ) for (const { credentialId: credId } of userAdminMemberships) { - const otherAdmins = await db + const otherAdmins = await tx .select({ id: credentialMember.id }) .from(credentialMember) .where( @@ -121,19 +130,19 @@ export async function revokeWorkspaceCredentialMemberships( if (otherAdmins.length > 0) continue const now = new Date() - const [existingOwnerMembership] = await db + const [existingOwnerMembership] = await tx .select({ id: credentialMember.id, status: credentialMember.status }) .from(credentialMember) .where(and(eq(credentialMember.credentialId, credId), eq(credentialMember.userId, ownerId))) .limit(1) if (existingOwnerMembership) { - await db + await tx .update(credentialMember) .set({ role: 'admin', status: 'active', updatedAt: now }) .where(eq(credentialMember.id, existingOwnerMembership.id)) } else { - await db.insert(credentialMember).values({ + await tx.insert(credentialMember).values({ id: generateId(), credentialId: credId, userId: ownerId, @@ -154,7 +163,7 @@ export async function revokeWorkspaceCredentialMemberships( } } - await db + await tx .update(credentialMember) .set({ status: 'revoked', updatedAt: new Date() }) .where( diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts new file mode 100644 index 00000000000..1f333fa6055 --- /dev/null +++ b/apps/sim/lib/invitations/core.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockEnsureUserInOrganization, + mockSetActiveOrganizationForCurrentSession, + mockSyncUsageLimitsFromSubscription, + mockSyncWorkspaceEnvCredentials, + mockApplyWorkspaceAutoAddGroup, +} = vi.hoisted(() => ({ + mockEnsureUserInOrganization: vi.fn(), + mockSetActiveOrganizationForCurrentSession: vi.fn(), + mockSyncUsageLimitsFromSubscription: vi.fn(), + mockSyncWorkspaceEnvCredentials: vi.fn(), + mockApplyWorkspaceAutoAddGroup: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + ensureUserInOrganization: mockEnsureUserInOrganization, +})) + +vi.mock('@/lib/auth/active-organization', () => ({ + setActiveOrganizationForCurrentSession: mockSetActiveOrganizationForCurrentSession, +})) + +vi.mock('@/lib/billing/core/usage', () => ({ + syncUsageLimitsFromSubscription: mockSyncUsageLimitsFromSubscription, +})) + +vi.mock('@/lib/credentials/environment', () => ({ + syncWorkspaceEnvCredentials: mockSyncWorkspaceEnvCredentials, +})) + +vi.mock('@/lib/permission-groups/auto-add', () => ({ + applyWorkspaceAutoAddGroup: mockApplyWorkspaceAutoAddGroup, +})) + +import { acceptInvitation } from '@/lib/invitations/core' + +function queueWhereResponses(responses: unknown[][]) { + const queue = [...responses] + dbChainMockFns.where.mockImplementation(() => { + const result = queue.shift() ?? [] + const thenable = Promise.resolve(result) as Promise & { + limit: ReturnType + orderBy: ReturnType + returning: ReturnType + groupBy: ReturnType + } + thenable.limit = vi.fn(() => Promise.resolve(result)) + thenable.orderBy = vi.fn(() => Promise.resolve(result)) + thenable.returning = vi.fn(() => Promise.resolve(result)) + thenable.groupBy = vi.fn(() => Promise.resolve(result)) + return thenable as ReturnType + }) +} + +describe('acceptInvitation', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('accepts external workspace invitations without joining the organization', async () => { + queueWhereResponses([ + [ + { + id: 'inv-1', + kind: 'workspace', + email: 'external@example.com', + organizationId: 'org-1', + membershipIntent: 'external', + inviterId: 'inviter-1', + role: 'member', + status: 'pending', + token: 'tok-1', + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + [ + { + id: 'grant-1', + workspaceId: 'workspace-1', + permission: 'write', + workspaceName: 'Workspace', + }, + ], + [{ name: 'Acme' }], + [{ name: 'Inviter', email: 'inviter@example.com' }], + [], + [], + [{ variables: {} }], + ]) + + const result = await acceptInvitation({ + userId: 'external-user', + userEmail: 'external@example.com', + invitationId: 'inv-1', + token: 'tok-1', + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.acceptedWorkspaceIds).toEqual(['workspace-1']) + expect(result.membershipAlreadyExists).toBe(false) + } + expect(mockEnsureUserInOrganization).not.toHaveBeenCalled() + expect(mockSetActiveOrganizationForCurrentSession).not.toHaveBeenCalled() + expect(mockSyncUsageLimitsFromSubscription).not.toHaveBeenCalled() + expect(mockApplyWorkspaceAutoAddGroup).toHaveBeenCalled() + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'external-user', + entityType: 'workspace', + entityId: 'workspace-1', + permissionType: 'write', + }) + ) + }) + + it('falls back to external access when an internal workspace invitee joined another organization', async () => { + mockEnsureUserInOrganization.mockResolvedValueOnce({ + success: false, + alreadyMember: false, + existingOrgId: 'org-2', + error: + 'User is already a member of another organization. Users can only belong to one organization at a time.', + billingActions: { + proUsageSnapshotted: false, + proCancelledAtPeriodEnd: false, + }, + }) + + queueWhereResponses([ + [ + { + id: 'inv-1', + kind: 'workspace', + email: 'invitee@example.com', + organizationId: 'org-1', + membershipIntent: 'internal', + inviterId: 'inviter-1', + role: 'member', + status: 'pending', + token: 'tok-1', + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + [ + { + id: 'grant-1', + workspaceId: 'workspace-1', + permission: 'read', + workspaceName: 'Workspace', + }, + ], + [{ name: 'Acme' }], + [{ name: 'Inviter', email: 'inviter@example.com' }], + [], + [], + [{ variables: {} }], + ]) + + const result = await acceptInvitation({ + userId: 'invitee-user', + userEmail: 'invitee@example.com', + invitationId: 'inv-1', + token: 'tok-1', + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.invitation.membershipIntent).toBe('external') + expect(result.acceptedWorkspaceIds).toEqual(['workspace-1']) + expect(result.membershipAlreadyExists).toBe(false) + } + expect(mockEnsureUserInOrganization).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'invitee-user', + organizationId: 'org-1', + acceptingInvitationId: 'inv-1', + }) + ) + expect(mockSetActiveOrganizationForCurrentSession).not.toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'accepted', + membershipIntent: 'external', + }) + ) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'invitee-user', + entityType: 'workspace', + entityId: 'workspace-1', + permissionType: 'read', + }) + ) + }) +}) diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 2565ac1b522..a60be68ccd2 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { type InvitationKind, + type InvitationMembershipIntent, type InvitationStatus, invitation, invitationWorkspaceGrant, @@ -35,6 +36,7 @@ export interface InvitationWithGrants { kind: InvitationKind email: string organizationId: string | null + membershipIntent: InvitationMembershipIntent inviterId: string role: string status: InvitationStatus @@ -100,6 +102,7 @@ async function hydrateInvitation( kind: row.kind, email: row.email, organizationId: row.organizationId, + membershipIntent: row.membershipIntent, inviterId: row.inviterId, role: row.role, status: row.status, @@ -254,8 +257,10 @@ export async function acceptInvitation( } let membershipAlreadyExists = false + let acceptedMembershipIntent = inv.membershipIntent + let shouldJoinOrganization = Boolean(inv.organizationId && inv.membershipIntent !== 'external') - if (inv.organizationId) { + if (shouldJoinOrganization && inv.organizationId) { const membershipResult = await ensureUserInOrganization({ userId: input.userId, organizationId: inv.organizationId, @@ -265,16 +270,21 @@ export async function acceptInvitation( if (!membershipResult.success) { if (membershipResult.existingOrgId) { - await db - .update(invitation) - .set({ status: 'rejected', updatedAt: new Date() }) - .where(eq(invitation.id, inv.id)) - return { success: false, kind: 'already-in-organization' } - } - if (membershipResult.error?.toLowerCase().includes('no available seats')) { + if (inv.kind === 'workspace' && inv.grants.length > 0) { + acceptedMembershipIntent = 'external' + shouldJoinOrganization = false + } else { + await db + .update(invitation) + .set({ status: 'rejected', updatedAt: new Date() }) + .where(eq(invitation.id, inv.id)) + return { success: false, kind: 'already-in-organization' } + } + } else if (membershipResult.failureCode === 'no-seats-available') { return { success: false, kind: 'no-seats-available' } + } else { + return { success: false, kind: 'server-error', message: membershipResult.error } } - return { success: false, kind: 'server-error', message: membershipResult.error } } membershipAlreadyExists = membershipResult.alreadyMember @@ -285,7 +295,11 @@ export async function acceptInvitation( await db.transaction(async (tx) => { await tx .update(invitation) - .set({ status: 'accepted', updatedAt: new Date() }) + .set({ + status: 'accepted', + membershipIntent: acceptedMembershipIntent, + updatedAt: new Date(), + }) .where(eq(invitation.id, inv.id)) for (const grant of inv.grants) { @@ -331,7 +345,7 @@ export async function acceptInvitation( } }) - if (inv.organizationId) { + if (shouldJoinOrganization && inv.organizationId) { try { await setActiveOrganizationForCurrentSession(inv.organizationId) } catch (activeOrgError) { @@ -369,7 +383,7 @@ export async function acceptInvitation( } } - if (inv.organizationId && !membershipAlreadyExists) { + if (shouldJoinOrganization && inv.organizationId && !membershipAlreadyExists) { try { await syncUsageLimitsFromSubscription(input.userId) } catch (syncError) { @@ -389,7 +403,7 @@ export async function acceptInvitation( return { success: true, - invitation: { ...inv, status: 'accepted' }, + invitation: { ...inv, status: 'accepted', membershipIntent: acceptedMembershipIntent }, acceptedWorkspaceIds, redirectPath, membershipAlreadyExists, @@ -444,6 +458,7 @@ export async function listPendingInvitationsForOrganization(organizationId: stri kind: invitation.kind, email: invitation.email, role: invitation.role, + membershipIntent: invitation.membershipIntent, status: invitation.status, expiresAt: invitation.expiresAt, createdAt: invitation.createdAt, @@ -468,6 +483,7 @@ export async function listInvitationsForWorkspaces(workspaceIds: string[]) { createdAt: invitation.createdAt, updatedAt: invitation.updatedAt, organizationId: invitation.organizationId, + membershipIntent: invitation.membershipIntent, inviterId: invitation.inviterId, workspaceId: invitationWorkspaceGrant.workspaceId, permission: invitationWorkspaceGrant.permission, diff --git a/apps/sim/lib/invitations/send.ts b/apps/sim/lib/invitations/send.ts index 8a1aa9b72eb..9be8170c229 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { type InvitationKind, + type InvitationMembershipIntent, invitation, invitationWorkspaceGrant, organization, @@ -8,7 +9,7 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, eq, inArray, ne, sql } from 'drizzle-orm' import { getEmailSubject, renderBatchInvitationEmail, @@ -32,6 +33,7 @@ export interface CreatePendingInvitationInput { email: string inviterId: string organizationId: string | null + membershipIntent?: InvitationMembershipIntent role: 'admin' | 'member' grants: WorkspaceGrantInput[] expiresAt?: Date @@ -58,6 +60,7 @@ export async function createPendingInvitation( email: normalizeEmail(input.email), inviterId: input.inviterId, organizationId: input.organizationId, + membershipIntent: input.membershipIntent ?? 'internal', role: input.role, status: 'pending', token, @@ -87,7 +90,13 @@ export async function countPendingInvitationsForOrganization( const [row] = await db .select({ count: sql`count(*)::int` }) .from(invitation) - .where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'))) + .where( + and( + eq(invitation.organizationId, organizationId), + eq(invitation.status, 'pending'), + ne(invitation.membershipIntent, 'external') + ) + ) return row?.count ?? 0 } diff --git a/apps/sim/lib/invitations/workspace-invitations.ts b/apps/sim/lib/invitations/workspace-invitations.ts new file mode 100644 index 00000000000..dad09c88f7b --- /dev/null +++ b/apps/sim/lib/invitations/workspace-invitations.ts @@ -0,0 +1,310 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { type InvitationMembershipIntent, permissions, user } from '@sim/db/schema' +import { and, eq, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { getUserOrganization } from '@/lib/billing/organizations/membership' +import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' +import { PlatformEvents } from '@/lib/core/telemetry' +import { normalizeEmail } from '@/lib/invitations/core' +import { + cancelPendingInvitation, + createPendingInvitation, + findPendingGrantForWorkspaceEmail, + sendInvitationEmail, +} from '@/lib/invitations/send' +import { captureServerEvent } from '@/lib/posthog/server' +import { + getWorkspaceWithOwner, + type PermissionType, + type WorkspaceWithOwner, +} from '@/lib/workspaces/permissions/utils' +import { getWorkspaceInvitePolicy, type WorkspaceInvitePolicy } from '@/lib/workspaces/policy' +import { validateInvitationsAllowed } from '@/ee/access-control/utils/permission-check' + +export interface WorkspaceInvitationContext { + workspaceId: string + inviterId: string + inviterName: string + inviterEmail?: string | null + workspaceDetails: WorkspaceWithOwner + invitePolicy: WorkspaceInvitePolicy +} + +export interface WorkspaceInvitationResult { + id: string + workspaceId: string + email: string + permission: PermissionType + membershipIntent: InvitationMembershipIntent + expiresAt: Date | undefined +} + +export class WorkspaceInvitationError extends Error { + status: number + email?: string + upgradeRequired?: boolean + + constructor({ + message, + status, + email, + upgradeRequired, + }: { + message: string + status: number + email?: string + upgradeRequired?: boolean + }) { + super(message) + this.name = 'WorkspaceInvitationError' + this.status = status + this.email = email + this.upgradeRequired = upgradeRequired + } +} + +export async function prepareWorkspaceInvitationContext({ + workspaceId, + inviterId, + inviterName, + inviterEmail, +}: { + workspaceId: string + inviterId: string + inviterName: string + inviterEmail?: string | null +}): Promise { + await validateInvitationsAllowed(inviterId, workspaceId) + + const userPermission = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.entityId, workspaceId), + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, inviterId), + eq(permissions.permissionType, 'admin') + ) + ) + .then((rows) => rows[0]) + + if (!userPermission) { + throw new WorkspaceInvitationError({ + message: 'You need admin permissions to invite users', + status: 403, + }) + } + + const workspaceDetails = await getWorkspaceWithOwner(workspaceId) + if (!workspaceDetails) { + throw new WorkspaceInvitationError({ message: 'Workspace not found', status: 404 }) + } + + const invitePolicy = await getWorkspaceInvitePolicy(workspaceDetails) + if (!invitePolicy.allowed) { + throw new WorkspaceInvitationError({ + message: invitePolicy.reason ?? 'Invites are disabled for this workspace.', + status: 403, + upgradeRequired: invitePolicy.upgradeRequired, + }) + } + + return { + workspaceId, + inviterId, + inviterName, + inviterEmail, + workspaceDetails, + invitePolicy, + } +} + +export async function createWorkspaceInvitation({ + context, + email, + permission = 'read', + request, +}: { + context: WorkspaceInvitationContext + email: string + permission?: string + request: NextRequest +}): Promise { + const validPermissions: PermissionType[] = ['admin', 'write', 'read'] + if (!validPermissions.includes(permission as PermissionType)) { + throw new WorkspaceInvitationError({ + message: `Invalid permission: must be one of ${validPermissions.join(', ')}`, + status: 400, + email, + }) + } + const invitationPermission = permission as PermissionType + + const normalizedEmail = normalizeEmail(email) + let membershipIntent: InvitationMembershipIntent = 'internal' + + const existingUser = await db + .select() + .from(user) + .where(sql`lower(${user.email}) = ${normalizedEmail}`) + .then((rows) => rows[0]) + + if (existingUser) { + const existingPermission = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.entityId, context.workspaceId), + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, existingUser.id) + ) + ) + .then((rows) => rows[0]) + + if (existingPermission) { + throw new WorkspaceInvitationError({ + message: `${normalizedEmail} already has access to this workspace`, + status: 400, + email: normalizedEmail, + }) + } + + if (context.invitePolicy.organizationId) { + const existingMembership = await getUserOrganization(existingUser.id) + if ( + existingMembership && + existingMembership.organizationId !== context.invitePolicy.organizationId + ) { + membershipIntent = 'external' + } else if (context.invitePolicy.requiresSeat && !existingMembership) { + const seatValidation = await validateSeatAvailability( + context.invitePolicy.organizationId, + 1 + ) + if (!seatValidation.canInvite) { + throw new WorkspaceInvitationError({ + message: seatValidation.reason || 'No available seats for this organization.', + status: 400, + email: normalizedEmail, + }) + } + } + } + } else if (context.invitePolicy.requiresSeat && context.invitePolicy.organizationId) { + const seatValidation = await validateSeatAvailability(context.invitePolicy.organizationId, 1) + if (!seatValidation.canInvite) { + throw new WorkspaceInvitationError({ + message: seatValidation.reason || 'No available seats for this organization.', + status: 400, + email: normalizedEmail, + }) + } + } + + const existingInvitation = await findPendingGrantForWorkspaceEmail({ + workspaceId: context.workspaceId, + email: normalizedEmail, + }) + if (existingInvitation) { + throw new WorkspaceInvitationError({ + message: `${normalizedEmail} has already been invited to this workspace`, + status: 400, + email: normalizedEmail, + }) + } + + const { invitationId, token } = await createPendingInvitation({ + kind: 'workspace', + email: normalizedEmail, + inviterId: context.inviterId, + organizationId: context.workspaceDetails.organizationId, + membershipIntent, + role: 'member', + grants: [ + { + workspaceId: context.workspaceId, + permission: invitationPermission, + }, + ], + }) + + try { + PlatformEvents.workspaceMemberInvited({ + workspaceId: context.workspaceId, + invitedBy: context.inviterId, + inviteeEmail: normalizedEmail, + role: invitationPermission, + membershipIntent, + }) + } catch { + /** + * Telemetry must not fail invitation creation. + */ + } + + captureServerEvent( + context.inviterId, + 'workspace_member_invited', + { + workspace_id: context.workspaceId, + invitee_role: invitationPermission, + membership_intent: membershipIntent, + }, + { + groups: { workspace: context.workspaceId }, + setOnce: { first_invitation_sent_at: new Date().toISOString() }, + } + ) + + const emailResult = await sendInvitationEmail({ + invitationId, + token, + kind: 'workspace', + email: normalizedEmail, + inviterName: context.inviterName, + organizationId: context.workspaceDetails.organizationId, + organizationRole: 'member', + grants: [{ workspaceId: context.workspaceId, permission: invitationPermission }], + }) + + if (!emailResult.success) { + await cancelPendingInvitation(invitationId) + throw new WorkspaceInvitationError({ + message: emailResult.error || 'Failed to send invitation email', + status: 502, + email: normalizedEmail, + }) + } + + recordAudit({ + workspaceId: context.workspaceId, + actorId: context.inviterId, + actorName: context.inviterName, + actorEmail: context.inviterEmail, + action: AuditAction.MEMBER_INVITED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: context.workspaceId, + resourceName: normalizedEmail, + description: `Invited ${normalizedEmail} as ${invitationPermission}`, + metadata: { + targetEmail: normalizedEmail, + targetRole: invitationPermission, + membershipIntent, + workspaceName: context.workspaceDetails.name, + invitationId, + }, + request, + }) + + return { + id: invitationId, + workspaceId: context.workspaceId, + email: normalizedEmail, + permission: invitationPermission, + membershipIntent, + expiresAt: undefined, + } +} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index c6cad228c58..fdb9e82a89c 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -88,6 +88,7 @@ export interface PostHogEventMap { workspace_member_invited: { workspace_id: string invitee_role: string + membership_intent?: string } workspace_member_removed: { diff --git a/apps/sim/lib/workspaces/organization/types.ts b/apps/sim/lib/workspaces/organization/types.ts index 333c193ac9a..5cef7f2e7ea 100644 --- a/apps/sim/lib/workspaces/organization/types.ts +++ b/apps/sim/lib/workspaces/organization/types.ts @@ -15,6 +15,7 @@ export interface Invitation { id: string email: string status: string + membershipIntent?: 'internal' | 'external' } export interface Organization { diff --git a/apps/sim/lib/workspaces/organization/utils.test.ts b/apps/sim/lib/workspaces/organization/utils.test.ts new file mode 100644 index 00000000000..1467dde1d08 --- /dev/null +++ b/apps/sim/lib/workspaces/organization/utils.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { calculateSeatUsage } from '@/lib/workspaces/organization/utils' + +describe('calculateSeatUsage', () => { + it('does not count external pending workspace invitations as occupied seats', () => { + const seats = calculateSeatUsage({ + id: 'org-1', + name: 'Acme', + slug: 'acme', + createdAt: new Date(), + members: [ + { id: 'member-1', role: 'owner' }, + { id: 'member-2', role: 'member' }, + ], + invitations: [ + { + id: 'inv-1', + email: 'internal@example.com', + status: 'pending', + membershipIntent: 'internal', + }, + { + id: 'inv-2', + email: 'external@example.com', + status: 'pending', + membershipIntent: 'external', + }, + ], + }) + + expect(seats).toEqual({ used: 3, members: 2, pending: 1 }) + }) +}) diff --git a/apps/sim/lib/workspaces/organization/utils.ts b/apps/sim/lib/workspaces/organization/utils.ts index b619677d1eb..21472fbf0d8 100644 --- a/apps/sim/lib/workspaces/organization/utils.ts +++ b/apps/sim/lib/workspaces/organization/utils.ts @@ -45,7 +45,9 @@ export function calculateSeatUsage(organization: Organization | null | undefined const membersCount = organization.members?.length || 0 const pendingInvitationsCount = - organization.invitations?.filter((inv) => inv.status === 'pending').length || 0 + organization.invitations?.filter( + (inv) => inv.status === 'pending' && inv.membershipIntent !== 'external' + ).length || 0 return { used: membersCount + pendingInvitationsCount, diff --git a/apps/sim/lib/workspaces/permissions/utils.test.ts b/apps/sim/lib/workspaces/permissions/utils.test.ts index 4833ef5cebc..7da1a893350 100644 --- a/apps/sim/lib/workspaces/permissions/utils.test.ts +++ b/apps/sim/lib/workspaces/permissions/utils.test.ts @@ -24,6 +24,7 @@ function createMockChain(finalResult: any) { chain.where = vi.fn().mockReturnValue(chain) chain.limit = vi.fn().mockReturnValue(chain) chain.innerJoin = vi.fn().mockReturnValue(chain) + chain.leftJoin = vi.fn().mockReturnValue(chain) chain.orderBy = vi.fn().mockReturnValue(chain) return chain @@ -225,10 +226,44 @@ describe('Permission Utils', () => { name: 'Alice Smith', image: 'https://example.com/alice.png', permissionType: 'admin', + isExternal: false, }, ]) }) + it('marks users as external when they are not members of the workspace organization', async () => { + const mockUsersResults = [ + { + userId: 'internal-user', + email: 'internal@example.com', + name: 'Internal User', + image: null, + permissionType: 'admin' as PermissionType, + workspaceOrganizationId: 'org-1', + organizationMemberId: 'member-1', + }, + { + userId: 'external-user', + email: 'external@example.com', + name: 'External User', + image: null, + permissionType: 'write' as PermissionType, + workspaceOrganizationId: 'org-1', + organizationMemberId: null, + }, + ] + + const usersChain = createMockChain(mockUsersResults) + mockDb.select.mockReturnValue(usersChain) + + const result = await getUsersWithPermissions('workspace456') + + expect(result.map((u) => ({ email: u.email, isExternal: u.isExternal }))).toEqual([ + { email: 'internal@example.com', isExternal: false }, + { email: 'external@example.com', isExternal: true }, + ]) + }) + it('should return multiple users with different permission levels', async () => { const mockUsersResults = [ { diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index 0c59174b26c..5926d7c6bdf 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -240,6 +240,7 @@ export async function getUsersWithPermissions(workspaceId: string): Promise< name: string image: string | null permissionType: PermissionType + isExternal: boolean }> > { const usersWithPermissions = await db @@ -249,10 +250,16 @@ export async function getUsersWithPermissions(workspaceId: string): Promise< name: user.name, image: user.image, permissionType: permissions.permissionType, + workspaceOrganizationId: workspace.organizationId, + organizationMemberId: member.id, }) .from(permissions) .innerJoin(user, eq(permissions.userId, user.id)) .innerJoin(workspace, eq(permissions.entityId, workspace.id)) + .leftJoin( + member, + and(eq(member.userId, user.id), eq(member.organizationId, workspace.organizationId)) + ) .where( and( eq(permissions.entityType, 'workspace'), @@ -268,6 +275,7 @@ export async function getUsersWithPermissions(workspaceId: string): Promise< name: row.name, image: row.image ?? null, permissionType: row.permissionType, + isExternal: Boolean(row.workspaceOrganizationId && !row.organizationMemberId), })) } diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 2c00593ff0f..22ed9cb08f0 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -219,6 +219,23 @@ describe('getWorkspaceCreationPolicy', () => { expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION) expect(result.reason).toContain('owners and admins') }) + + it('blocks users without org membership from creating workspaces in the active org context', async () => { + mockDbResults.value = [[], [{ userId: 'owner-1' }]] + + const result = await getWorkspaceCreationPolicy({ + userId: 'external-user-1', + activeOrganizationId: 'org-1', + }) + + expect(result.canCreate).toBe(false) + expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION) + expect(result.organizationId).toBe('org-1') + expect(result.billedAccountUserId).toBe('owner-1') + expect(result.reason).toContain('owners and admins') + expect(mockGetOrganizationSubscription).not.toHaveBeenCalled() + expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() + }) }) describe('getWorkspaceInvitePolicy', () => { diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 67a008a5a23..100ac51035f 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -182,6 +182,21 @@ export async function getWorkspaceCreationPolicy({ .limit(1) )[0]?.role + if (activeOrganizationId && !orgRole) { + const billedAccountUserId = await requireOrganizationOwnerId(activeOrganizationId) + + return { + canCreate: false, + workspaceMode: WORKSPACE_MODE.ORGANIZATION, + organizationId: activeOrganizationId, + billedAccountUserId, + maxWorkspaces: null, + currentWorkspaceCount: 0, + reason: 'Only organization owners and admins can create organization workspaces.', + status: 403, + } + } + if (!isBillingEnabled) { if (organizationId && orgRole) { const billedAccountUserId = await requireOrganizationOwnerId(organizationId) diff --git a/packages/db/migrations/0199_invitation_membership_intent.sql b/packages/db/migrations/0199_invitation_membership_intent.sql new file mode 100644 index 00000000000..c392405fe3f --- /dev/null +++ b/packages/db/migrations/0199_invitation_membership_intent.sql @@ -0,0 +1,2 @@ +CREATE TYPE "public"."invitation_membership_intent" AS ENUM('internal', 'external');--> statement-breakpoint +ALTER TABLE "invitation" ADD COLUMN "membership_intent" "invitation_membership_intent" DEFAULT 'internal' NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/meta/0199_snapshot.json b/packages/db/migrations/meta/0199_snapshot.json new file mode 100644 index 00000000000..a1414c315a1 --- /dev/null +++ b/packages/db/migrations/meta/0199_snapshot.json @@ -0,0 +1,15239 @@ +{ + "id": "2f0094a8-ee5a-48f3-946f-f0c7abc2a0d7", + "prevId": "502ccfa5-4fd5-495f-ae22-3cdb2d6c72c4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"a2a_agent\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_archived_at_idx": { + "name": "a2a_agent_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_archived_partial_idx": { + "name": "a2a_agent_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"a2a_agent\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form": { + "name": "form", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "show_branding": { + "name": "show_branding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_identifier_idx": { + "name": "form_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"form\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_workflow_id_idx": { + "name": "form_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_user_id_idx": { + "name": "form_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_archived_at_partial_idx": { + "name": "form_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"form\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_workflow_id_workflow_id_fk": { + "name": "form_workflow_id_workflow_id_fk", + "tableFrom": "form", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_user_id_user_id_fk": { + "name": "form_user_id_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_access_token_access_token_idx": { + "name": "oauth_access_token_access_token_idx", + "columns": [ + { + "expression": "access_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_token_idx": { + "name": "oauth_access_token_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_application_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_application", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_access_token_unique": { + "name": "oauth_access_token_access_token_unique", + "nullsNotDistinct": false, + "columns": ["access_token"] + }, + "oauth_access_token_refresh_token_unique": { + "name": "oauth_access_token_refresh_token_unique", + "nullsNotDistinct": false, + "columns": ["refresh_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_application": { + "name": "oauth_application", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_application_client_id_idx": { + "name": "oauth_application_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_application_user_id_user_id_fk": { + "name": "oauth_application_user_id_user_id_fk", + "tableFrom": "oauth_application", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_application_client_id_unique": { + "name": "oauth_application_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_user_client_idx": { + "name": "oauth_consent_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_application_client_id_fk": { + "name": "oauth_consent_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_application", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_name_unique": { + "name": "permission_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_auto_add_unique": { + "name": "permission_group_workspace_auto_add_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_id_workspace_id_fk", + "tableFrom": "permission_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_workspace_user_unique": { + "name": "permission_group_member_workspace_user_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_workspace_id_workspace_id_fk": { + "name": "permission_group_member_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_a2a_executions": { + "name": "total_a2a_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_table_id_idx": { + "name": "user_table_rows_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_data_gin_idx": { + "name": "user_table_rows_data_gin_idx", + "columns": [ + { + "expression": "data", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id": { + "name": "idx_webhook_on_workflow_id_block_id", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_name_active_unique": { + "name": "workspace_files_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 9e007ac1a76..e0fd90a9ef8 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1387,6 +1387,13 @@ "when": 1777054484443, "tag": "0198_tables_race_free_trigger", "breakpoints": true + }, + { + "idx": 199, + "version": "7", + "when": 1777325415453, + "tag": "0199_invitation_membership_intent", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 15c284688db..269a7b19f37 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1016,6 +1016,13 @@ export const invitationKindEnum = pgEnum('invitation_kind', ['organization', 'wo export type InvitationKind = (typeof invitationKindEnum.enumValues)[number] +export const invitationMembershipIntentEnum = pgEnum('invitation_membership_intent', [ + 'internal', + 'external', +]) + +export type InvitationMembershipIntent = (typeof invitationMembershipIntentEnum.enumValues)[number] + export const invitationStatusEnum = pgEnum('invitation_status', [ 'pending', 'accepted', @@ -1038,6 +1045,9 @@ export const invitation = pgTable( organizationId: text('organization_id').references(() => organization.id, { onDelete: 'cascade', }), + membershipIntent: invitationMembershipIntentEnum('membership_intent') + .notNull() + .default('internal'), role: text('role').notNull(), status: invitationStatusEnum('status').notNull().default('pending'), token: text('token').notNull().unique(), diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 6a632feca15..78fa916348a 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -468,6 +468,7 @@ export const schemaMock = { createdAt: 'createdAt', }, invitationKindEnum: { enumValues: ['organization', 'workspace'] as const }, + invitationMembershipIntentEnum: { enumValues: ['internal', 'external'] as const }, invitationStatusEnum: { enumValues: ['pending', 'accepted', 'rejected', 'cancelled', 'expired'] as const, }, @@ -477,6 +478,7 @@ export const schemaMock = { email: 'email', inviterId: 'inviterId', organizationId: 'organizationId', + membershipIntent: 'membershipIntent', role: 'role', status: 'status', token: 'token', From c400e59ea6d0d4574eed5bebc9126e15a7842992 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 27 Apr 2026 23:41:56 -0700 Subject: [PATCH 10/28] feat(sap_s4hana): add get_material_document and fix supplier invoice key order (#4317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sap_s4hana): require non-empty items in create_purchase_order Why: SAP A_PurchaseOrder POST silently fails or returns opaque errors without to_PurchaseOrderItem entries. Block already required this body but the tool marked it optional and didn't validate items presence — mismatched contract with create_sales_order / create_purchase_requisition. Also clarifies the deliveryDocument placeholder to show both outbound and inbound number ranges. Co-Authored-By: Claude Opus 4.7 * feat(sap_s4hana): add get_material_document and fix supplier invoice key order Co-Authored-By: Claude Opus 4.7 * fix(sap_s4hana): align material doc key order in description and require purchase order body type --------- Co-authored-by: Claude Opus 4.7 --- .../docs/content/docs/en/tools/sap_s4hana.mdx | 32 ++++- apps/docs/content/docs/en/tools/slack.mdx | 133 ++++++++++++++++++ .../integrations/data/integrations.json | 24 +++- apps/sim/blocks/blocks/sap_s4hana.ts | 33 ++++- apps/sim/tools/registry.ts | 2 + .../tools/sap_s4hana/create_purchase_order.ts | 10 +- .../tools/sap_s4hana/get_material_document.ts | 122 ++++++++++++++++ .../tools/sap_s4hana/get_supplier_invoice.ts | 2 +- apps/sim/tools/sap_s4hana/index.ts | 1 + apps/sim/tools/sap_s4hana/types.ts | 9 +- 10 files changed, 360 insertions(+), 8 deletions(-) create mode 100644 apps/sim/tools/sap_s4hana/get_material_document.ts diff --git a/apps/docs/content/docs/en/tools/sap_s4hana.mdx b/apps/docs/content/docs/en/tools/sap_s4hana.mdx index 1647bf5f706..57fde4ba95d 100644 --- a/apps/docs/content/docs/en/tools/sap_s4hana.mdx +++ b/apps/docs/content/docs/en/tools/sap_s4hana.mdx @@ -844,6 +844,36 @@ List material document headers (goods movements) from SAP S/4HANA Cloud (API_MAT | `status` | number | HTTP status code returned by SAP | | `data` | json | Array of A_MaterialDocumentHeader entities | +### `sap_s4hana_get_material_document` + +Retrieve a single material document header by composite key (MaterialDocument + MaterialDocumentYear) from SAP S/4HANA Cloud (API_MATERIAL_DOCUMENT_SRV, A_MaterialDocumentHeader). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subdomain` | string | Yes | SAP BTP subaccount subdomain \(technical name of your subaccount, not the S/4HANA host\) | +| `region` | string | Yes | BTP region \(e.g. eu10, us10\) | +| `clientId` | string | Yes | OAuth client ID from the S/4HANA Communication Arrangement | +| `clientSecret` | string | Yes | OAuth client secret from the S/4HANA Communication Arrangement | +| `deploymentType` | string | No | Deployment type: cloud_public \(default\), cloud_private, or on_premise | +| `authType` | string | No | Authentication type: oauth_client_credentials \(default\) or basic | +| `baseUrl` | string | No | Base URL of the S/4HANA host \(Cloud Private / On-Premise\) | +| `tokenUrl` | string | No | OAuth token URL \(Cloud Private / On-Premise + OAuth\) | +| `username` | string | No | Username for HTTP Basic auth | +| `password` | string | No | Password for HTTP Basic auth | +| `materialDocumentYear` | string | Yes | MaterialDocumentYear \(4-character year, e.g., "2024"\) | +| `materialDocument` | string | Yes | MaterialDocument key \(string, up to 10 characters\) | +| `select` | string | No | Comma-separated fields to return \($select\) | +| `expand` | string | No | Comma-separated navigation properties to expand \(e.g., "to_MaterialDocumentItem"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status code returned by SAP | +| `data` | json | A_MaterialDocumentHeader entity | + ### `sap_s4hana_list_purchase_requisitions` List purchase requisitions from SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV, A_PurchaseRequisitionHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand. Note: API_PURCHASEREQ_PROCESS_SRV is deprecated since S/4HANA Cloud Public Edition 2402; the successor is API_PURCHASEREQUISITION_2 (OData v4). This tool still works against tenants where the legacy service is enabled. @@ -1047,7 +1077,7 @@ Create a purchase order in SAP S/4HANA Cloud (API_PURCHASEORDER_PROCESS_SRV, A_P | `purchasingOrganization` | string | Yes | PurchasingOrganization \(4 chars\) | | `purchasingGroup` | string | Yes | PurchasingGroup \(3 chars\) | | `supplier` | string | Yes | Supplier business partner key \(up to 10 chars\) | -| `body` | json | No | Additional A_PurchaseOrder fields and to_PurchaseOrderItem deep-insert items merged into the create payload \(e.g., \{"to_PurchaseOrderItem":\[\{"PurchaseOrderItem":"10","Material":"TG11","OrderQuantity":"5","Plant":"1010","PurchaseOrderQuantityUnit":"PC","NetPriceAmount":"100.00","DocumentCurrency":"USD"\}\]\}\). | +| `body` | json | Yes | A_PurchaseOrder body containing to_PurchaseOrderItem deep-insert items \(required by SAP\) plus any additional header fields, e.g., \{"to_PurchaseOrderItem":\[\{"PurchaseOrderItem":"10","Material":"TG11","OrderQuantity":"5","Plant":"1010","PurchaseOrderQuantityUnit":"PC","NetPriceAmount":"100.00","DocumentCurrency":"USD"\}\]\}. | #### Output diff --git a/apps/docs/content/docs/en/tools/slack.mdx b/apps/docs/content/docs/en/tools/slack.mdx index 0c0a000e9c4..61884d2cc6e 100644 --- a/apps/docs/content/docs/en/tools/slack.mdx +++ b/apps/docs/content/docs/en/tools/slack.mdx @@ -925,6 +925,139 @@ Create a canvas pinned to a Slack channel as its resource hub | --------- | ---- | ----------- | | `canvas_id` | string | ID of the created channel canvas | +### `slack_get_canvas` + +Get Slack canvas file metadata by canvas ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `canvasId` | string | Yes | Canvas file ID to retrieve \(e.g., F1234ABCD\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `canvas` | object | Canvas file information returned by Slack | +| ↳ `id` | string | Unique canvas file identifier | +| ↳ `created` | number | Unix timestamp when the canvas was created | +| ↳ `timestamp` | number | Unix timestamp associated with the canvas | +| ↳ `name` | string | Canvas file name | +| ↳ `title` | string | Canvas title | +| ↳ `mimetype` | string | MIME type of the canvas file | +| ↳ `filetype` | string | Slack file type for the canvas | +| ↳ `pretty_type` | string | Human-readable file type | +| ↳ `user` | string | User ID of the canvas creator | +| ↳ `editable` | boolean | Whether the canvas file is editable | +| ↳ `size` | number | Canvas file size in bytes | +| ↳ `mode` | string | File mode | +| ↳ `is_external` | boolean | Whether the canvas is externally hosted | +| ↳ `is_public` | boolean | Whether the canvas is public | +| ↳ `url_private` | string | Private URL for the canvas file | +| ↳ `url_private_download` | string | Private download URL for the canvas file | +| ↳ `permalink` | string | Permanent URL for the canvas | +| ↳ `channels` | array | Public channel IDs where the canvas appears | +| ↳ `groups` | array | Private channel IDs where the canvas appears | +| ↳ `ims` | array | Direct message IDs where the canvas appears | +| ↳ `canvas_readtime` | number | Approximate read time for canvas content | +| ↳ `is_channel_space` | boolean | Whether this canvas is linked to a channel | +| ↳ `linked_channel_id` | string | Channel ID linked to this canvas | +| ↳ `canvas_creator_id` | string | User ID of the canvas creator | + +### `slack_list_canvases` + +List Slack canvases available to the authenticated user or bot + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | No | Filter canvases appearing in a specific channel ID | +| `count` | number | No | Number of canvases to return per page | +| `page` | number | No | Page number to return | +| `user` | string | No | Filter canvases created by a single user ID | +| `tsFrom` | string | No | Filter canvases created after this Unix timestamp | +| `tsTo` | string | No | Filter canvases created before this Unix timestamp | +| `teamId` | string | No | Encoded team ID, required when using an org-level token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `canvases` | array | Canvas file objects returned by Slack | +| ↳ `id` | string | Unique canvas file identifier | +| ↳ `created` | number | Unix timestamp when the canvas was created | +| ↳ `timestamp` | number | Unix timestamp associated with the canvas | +| ↳ `name` | string | Canvas file name | +| ↳ `title` | string | Canvas title | +| ↳ `mimetype` | string | MIME type of the canvas file | +| ↳ `filetype` | string | Slack file type for the canvas | +| ↳ `pretty_type` | string | Human-readable file type | +| ↳ `user` | string | User ID of the canvas creator | +| ↳ `editable` | boolean | Whether the canvas file is editable | +| ↳ `size` | number | Canvas file size in bytes | +| ↳ `mode` | string | File mode | +| ↳ `is_external` | boolean | Whether the canvas is externally hosted | +| ↳ `is_public` | boolean | Whether the canvas is public | +| ↳ `url_private` | string | Private URL for the canvas file | +| ↳ `url_private_download` | string | Private download URL for the canvas file | +| ↳ `permalink` | string | Permanent URL for the canvas | +| ↳ `channels` | array | Public channel IDs where the canvas appears | +| ↳ `groups` | array | Private channel IDs where the canvas appears | +| ↳ `ims` | array | Direct message IDs where the canvas appears | +| ↳ `canvas_readtime` | number | Approximate read time for canvas content | +| ↳ `is_channel_space` | boolean | Whether this canvas is linked to a channel | +| ↳ `linked_channel_id` | string | Channel ID linked to this canvas | +| ↳ `canvas_creator_id` | string | User ID of the canvas creator | +| `paging` | object | Pagination information from Slack | +| ↳ `count` | number | Number of items requested per page | +| ↳ `total` | number | Total number of matching files | +| ↳ `page` | number | Current page number | +| ↳ `pages` | number | Total number of pages | + +### `slack_lookup_canvas_sections` + +Find Slack canvas section IDs matching criteria for later edits + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `canvasId` | string | Yes | Canvas ID to search \(e.g., F1234ABCD\) | +| `criteria` | json | Yes | Section lookup criteria, such as \{"section_types":\["h1"\],"contains_text":"Roadmap"\} | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sections` | array | Canvas sections matching the lookup criteria | +| ↳ `id` | string | Canvas section identifier | + +### `slack_delete_canvas` + +Delete a Slack canvas by its canvas ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `canvasId` | string | Yes | Canvas ID to delete \(e.g., F1234ABCD\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ok` | boolean | Whether Slack deleted the canvas successfully | + ### `slack_create_conversation` Create a new public or private channel in a Slack workspace. diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index c6bdf4c25c6..1f8c92d35fe 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -11493,6 +11493,10 @@ "name": "List Material Documents", "description": "List material document headers (goods movements) from SAP S/4HANA Cloud (API_MATERIAL_DOCUMENT_SRV, A_MaterialDocumentHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand." }, + { + "name": "Get Material Document", + "description": "Retrieve a single material document header by composite key (MaterialDocument + MaterialDocumentYear) from SAP S/4HANA Cloud (API_MATERIAL_DOCUMENT_SRV, A_MaterialDocumentHeader)." + }, { "name": "List Purchase Requisitions", "description": "List purchase requisitions from SAP S/4HANA Cloud (API_PURCHASEREQ_PROCESS_SRV, A_PurchaseRequisitionHeader) with optional OData $filter, $top, $skip, $orderby, $select, $expand. Note: API_PURCHASEREQ_PROCESS_SRV is deprecated since S/4HANA Cloud Public Edition 2402; the successor is API_PURCHASEREQUISITION_2 (OData v4). This tool still works against tenants where the legacy service is enabled." @@ -11538,7 +11542,7 @@ "description": "Make an arbitrary OData v2 call against any SAP S/4HANA Cloud whitelisted Communication Scenario. Use when no dedicated tool exists for the entity. The proxy handles auth, CSRF, and OData unwrapping." } ], - "operationCount": 37, + "operationCount": 38, "triggers": [], "triggerCount": 0, "authType": "none", @@ -12150,6 +12154,22 @@ "name": "Create Channel Canvas", "description": "Create a canvas pinned to a Slack channel as its resource hub" }, + { + "name": "Get Canvas Info", + "description": "Get Slack canvas file metadata by canvas ID" + }, + { + "name": "List Canvases", + "description": "List Slack canvases available to the authenticated user or bot" + }, + { + "name": "Lookup Canvas Sections", + "description": "Find Slack canvas section IDs matching criteria for later edits" + }, + { + "name": "Delete Canvas", + "description": "Delete a Slack canvas by its canvas ID" + }, { "name": "Create Conversation", "description": "Create a new public or private channel in a Slack workspace." @@ -12175,7 +12195,7 @@ "description": "Publish a static view to a user" } ], - "operationCount": 25, + "operationCount": 29, "triggers": [ { "id": "slack_webhook", diff --git a/apps/sim/blocks/blocks/sap_s4hana.ts b/apps/sim/blocks/blocks/sap_s4hana.ts index ec0e878024f..fc5cd94c66c 100644 --- a/apps/sim/blocks/blocks/sap_s4hana.ts +++ b/apps/sim/blocks/blocks/sap_s4hana.ts @@ -48,6 +48,7 @@ export const SapS4HanaBlock: BlockConfig = { { label: 'Update Product', id: 'sap_s4hana_update_product' }, { label: 'List Material Stock', id: 'sap_s4hana_list_material_stock' }, { label: 'List Material Documents', id: 'sap_s4hana_list_material_documents' }, + { label: 'Get Material Document', id: 'sap_s4hana_get_material_document' }, { label: 'List Purchase Requisitions', id: 'sap_s4hana_list_purchase_requisitions' }, { label: 'Get Purchase Requisition', id: 'sap_s4hana_get_purchase_requisition' }, { label: 'Create Purchase Requisition', id: 'sap_s4hana_create_purchase_requisition' }, @@ -189,6 +190,7 @@ export const SapS4HanaBlock: BlockConfig = { 'sap_s4hana_get_product', 'sap_s4hana_list_material_stock', 'sap_s4hana_list_material_documents', + 'sap_s4hana_get_material_document', 'sap_s4hana_list_purchase_requisitions', 'sap_s4hana_get_purchase_requisition', 'sap_s4hana_list_purchase_orders', @@ -225,6 +227,7 @@ export const SapS4HanaBlock: BlockConfig = { 'sap_s4hana_get_product', 'sap_s4hana_list_material_stock', 'sap_s4hana_list_material_documents', + 'sap_s4hana_get_material_document', 'sap_s4hana_list_purchase_requisitions', 'sap_s4hana_get_purchase_requisition', 'sap_s4hana_list_purchase_orders', @@ -417,7 +420,7 @@ export const SapS4HanaBlock: BlockConfig = { id: 'deliveryDocument', title: 'DeliveryDocument', type: 'short-input', - placeholder: '80000000', + placeholder: 'e.g., 80000000 (outbound) or 180000000 (inbound)', condition: { field: 'operation', value: ['sap_s4hana_get_outbound_delivery', 'sap_s4hana_get_inbound_delivery'], @@ -556,6 +559,24 @@ export const SapS4HanaBlock: BlockConfig = { required: true, }, + // Material Document: get + { + id: 'materialDocumentYear', + title: 'MaterialDocumentYear', + type: 'short-input', + placeholder: '2024', + condition: { field: 'operation', value: 'sap_s4hana_get_material_document' }, + required: true, + }, + { + id: 'materialDocument', + title: 'MaterialDocument', + type: 'short-input', + placeholder: '4900000000', + condition: { field: 'operation', value: 'sap_s4hana_get_material_document' }, + required: true, + }, + // Supplier Invoice: get { id: 'supplierInvoice', @@ -844,6 +865,7 @@ export const SapS4HanaBlock: BlockConfig = { 'sap_s4hana_update_product', 'sap_s4hana_list_material_stock', 'sap_s4hana_list_material_documents', + 'sap_s4hana_get_material_document', 'sap_s4hana_list_purchase_requisitions', 'sap_s4hana_get_purchase_requisition', 'sap_s4hana_create_purchase_requisition', @@ -991,6 +1013,13 @@ export const SapS4HanaBlock: BlockConfig = { return { ...auth, ...listFields } case 'sap_s4hana_list_material_documents': return { ...auth, ...listFields } + case 'sap_s4hana_get_material_document': + return { + ...auth, + ...entityFields, + materialDocumentYear: params.materialDocumentYear, + materialDocument: params.materialDocument, + } case 'sap_s4hana_list_purchase_requisitions': return { ...auth, ...listFields } case 'sap_s4hana_get_purchase_requisition': @@ -1124,6 +1153,8 @@ export const SapS4HanaBlock: BlockConfig = { purchaseOrderBody: { type: 'json', description: 'Items and additional A_PurchaseOrder fields' }, supplierInvoice: { type: 'string', description: 'SupplierInvoice key' }, fiscalYear: { type: 'string', description: 'FiscalYear (4-digit year)' }, + materialDocumentYear: { type: 'string', description: 'MaterialDocumentYear (4-digit year)' }, + materialDocument: { type: 'string', description: 'MaterialDocument key' }, odataService: { type: 'string', description: 'OData service name' }, odataPath: { type: 'string', description: 'OData entity path' }, odataMethod: { type: 'string', description: 'HTTP method for OData call' }, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 4a02631d70e..af74992d5d7 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2258,6 +2258,7 @@ import { getBusinessPartnerTool as sapS4HanaGetBusinessPartnerTool, getCustomerTool as sapS4HanaGetCustomerTool, getInboundDeliveryTool as sapS4HanaGetInboundDeliveryTool, + getMaterialDocumentTool as sapS4HanaGetMaterialDocumentTool, getOutboundDeliveryTool as sapS4HanaGetOutboundDeliveryTool, getProductTool as sapS4HanaGetProductTool, getPurchaseOrderTool as sapS4HanaGetPurchaseOrderTool, @@ -5334,6 +5335,7 @@ export const tools: Record = { sap_s4hana_get_business_partner: sapS4HanaGetBusinessPartnerTool, sap_s4hana_get_customer: sapS4HanaGetCustomerTool, sap_s4hana_get_inbound_delivery: sapS4HanaGetInboundDeliveryTool, + sap_s4hana_get_material_document: sapS4HanaGetMaterialDocumentTool, sap_s4hana_get_outbound_delivery: sapS4HanaGetOutboundDeliveryTool, sap_s4hana_get_product: sapS4HanaGetProductTool, sap_s4hana_get_purchase_order: sapS4HanaGetPurchaseOrderTool, diff --git a/apps/sim/tools/sap_s4hana/create_purchase_order.ts b/apps/sim/tools/sap_s4hana/create_purchase_order.ts index 04dcc8662d7..68e5dfcd32f 100644 --- a/apps/sim/tools/sap_s4hana/create_purchase_order.ts +++ b/apps/sim/tools/sap_s4hana/create_purchase_order.ts @@ -107,10 +107,10 @@ export const createPurchaseOrderTool: ToolConfig ({ 'Content-Type': 'application/json' }), body: (params) => { const extra = parseJsonInput>(params.body, 'body') ?? {} + const items = Array.isArray(extra.to_PurchaseOrderItem) ? extra.to_PurchaseOrderItem : null + if (!items || items.length === 0) { + throw new Error( + 'body must include a non-empty "to_PurchaseOrderItem" array of purchase order line items' + ) + } const payload: Record = { ...extra, PurchaseOrderType: params.purchaseOrderType, diff --git a/apps/sim/tools/sap_s4hana/get_material_document.ts b/apps/sim/tools/sap_s4hana/get_material_document.ts new file mode 100644 index 00000000000..d360abbf141 --- /dev/null +++ b/apps/sim/tools/sap_s4hana/get_material_document.ts @@ -0,0 +1,122 @@ +import type { GetMaterialDocumentParams, SapProxyResponse } from '@/tools/sap_s4hana/types' +import { + baseProxyBody, + buildEntityQuery, + quoteOdataKey, + SAP_PROXY_URL, + transformSapProxyResponse, +} from '@/tools/sap_s4hana/utils' +import type { ToolConfig } from '@/tools/types' + +export const getMaterialDocumentTool: ToolConfig = { + id: 'sap_s4hana_get_material_document', + name: 'SAP S/4HANA Get Material Document', + description: + 'Retrieve a single material document header by composite key (MaterialDocument + MaterialDocumentYear) from SAP S/4HANA Cloud (API_MATERIAL_DOCUMENT_SRV, A_MaterialDocumentHeader).', + version: '1.0.0', + params: { + subdomain: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'SAP BTP subaccount subdomain (technical name of your subaccount, not the S/4HANA host)', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'BTP region (e.g. eu10, us10)', + }, + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client ID from the S/4HANA Communication Arrangement', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'OAuth client secret from the S/4HANA Communication Arrangement', + }, + deploymentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Deployment type: cloud_public (default), cloud_private, or on_premise', + }, + authType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication type: oauth_client_credentials (default) or basic', + }, + baseUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Base URL of the S/4HANA host (Cloud Private / On-Premise)', + }, + tokenUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'OAuth token URL (Cloud Private / On-Premise + OAuth)', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Username for HTTP Basic auth', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Password for HTTP Basic auth', + }, + materialDocumentYear: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'MaterialDocumentYear (4-character year, e.g., "2024")', + }, + materialDocument: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'MaterialDocument key (string, up to 10 characters)', + }, + select: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to return ($select)', + }, + expand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated navigation properties to expand (e.g., "to_MaterialDocumentItem")', + }, + }, + request: { + url: SAP_PROXY_URL, + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + ...baseProxyBody(params), + service: 'API_MATERIAL_DOCUMENT_SRV', + path: `/A_MaterialDocumentHeader(MaterialDocument=${quoteOdataKey(params.materialDocument)},MaterialDocumentYear=${quoteOdataKey(params.materialDocumentYear)})`, + method: 'GET', + query: buildEntityQuery(params), + }), + }, + transformResponse: transformSapProxyResponse, + outputs: { + status: { type: 'number', description: 'HTTP status code returned by SAP' }, + data: { type: 'json', description: 'A_MaterialDocumentHeader entity' }, + }, +} diff --git a/apps/sim/tools/sap_s4hana/get_supplier_invoice.ts b/apps/sim/tools/sap_s4hana/get_supplier_invoice.ts index 9e5c3ac9537..b27bb533e5b 100644 --- a/apps/sim/tools/sap_s4hana/get_supplier_invoice.ts +++ b/apps/sim/tools/sap_s4hana/get_supplier_invoice.ts @@ -108,7 +108,7 @@ export const getSupplierInvoiceTool: ToolConfig ({ ...baseProxyBody(params), service: 'API_SUPPLIERINVOICE_PROCESS_SRV', - path: `/A_SupplierInvoice(SupplierInvoice=${quoteOdataKey(params.supplierInvoice)},FiscalYear=${quoteOdataKey(params.fiscalYear)})`, + path: `/A_SupplierInvoice(FiscalYear=${quoteOdataKey(params.fiscalYear)},SupplierInvoice=${quoteOdataKey(params.supplierInvoice)})`, method: 'GET', query: buildEntityQuery(params), }), diff --git a/apps/sim/tools/sap_s4hana/index.ts b/apps/sim/tools/sap_s4hana/index.ts index 0115fdfe7c1..fb6a4a9d7bc 100644 --- a/apps/sim/tools/sap_s4hana/index.ts +++ b/apps/sim/tools/sap_s4hana/index.ts @@ -7,6 +7,7 @@ export { getBillingDocumentTool } from '@/tools/sap_s4hana/get_billing_document' export { getBusinessPartnerTool } from '@/tools/sap_s4hana/get_business_partner' export { getCustomerTool } from '@/tools/sap_s4hana/get_customer' export { getInboundDeliveryTool } from '@/tools/sap_s4hana/get_inbound_delivery' +export { getMaterialDocumentTool } from '@/tools/sap_s4hana/get_material_document' export { getOutboundDeliveryTool } from '@/tools/sap_s4hana/get_outbound_delivery' export { getProductTool } from '@/tools/sap_s4hana/get_product' export { getPurchaseOrderTool } from '@/tools/sap_s4hana/get_purchase_order' diff --git a/apps/sim/tools/sap_s4hana/types.ts b/apps/sim/tools/sap_s4hana/types.ts index c8103a212e5..a37d525cdf5 100644 --- a/apps/sim/tools/sap_s4hana/types.ts +++ b/apps/sim/tools/sap_s4hana/types.ts @@ -110,7 +110,7 @@ export interface CreatePurchaseOrderParams extends SapBaseParams { purchasingOrganization: string purchasingGroup: string supplier: string - body?: Record | string + body: Record | string } export interface ListSupplierInvoicesParams extends SapBaseParams { @@ -228,6 +228,13 @@ export interface GetInboundDeliveryParams extends SapBaseParams { expand?: string } +export interface GetMaterialDocumentParams extends SapBaseParams { + materialDocumentYear: string + materialDocument: string + select?: string + expand?: string +} + export interface ListMaterialDocumentsParams extends SapBaseParams { filter?: string top?: number From 3afcad218980b5b9a3c82bc0a4955d97f35fb8cf Mon Sep 17 00:00:00 2001 From: Octopus Date: Wed, 29 Apr 2026 01:57:16 +0800 Subject: [PATCH 11/28] fix(short-input): hide selected text to prevent overlay collision (#4318) * v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha * fix(short-input): hide selected text to prevent overlay collision The ShortInput component uses a transparent input layer beneath a formatted overlay. When users selected text and scrolled horizontally, the browser's ::selection pseudo-element overrode `color: transparent`, making raw unformatted text visible and causing visual collision with the overlay. Adding `selection:text-transparent` keeps the input text invisible under selection, while the overlay continues to display the correctly formatted highlighted text. Fixes #3389 Co-Authored-By: Octopus --------- Co-authored-by: Waleed Co-authored-by: Theodore Li Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Co-authored-by: Vikhyath Mondreti Co-authored-by: Theodore Li Co-authored-by: octo-patch --- .../components/sub-block/components/short-input/short-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx index ba5ff8461b1..48cdfa2e413 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx @@ -347,7 +347,7 @@ export const ShortInput = memo(function ShortInput({ <> } - className='allow-scroll w-full overflow-auto text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden' + className='allow-scroll w-full overflow-auto text-transparent selection:text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden' readOnly={readOnly} placeholder={placeholder ?? ''} type='text' From 69dc2f04b87235eb37220f6be2ad02f852153a7a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 28 Apr 2026 13:05:19 -0700 Subject: [PATCH 12/28] fix(mcp): Use SDK web-standard transport for copilot mcp (#4320) * fix(api): return 499 on copilot mcp user aborts * fix(mcp): fix copilot mcp response --- apps/sim/app/api/mcp/copilot/route.ts | 221 ++------------------------ 1 file changed, 11 insertions(+), 210 deletions(-) diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index 6ae73c4126d..93e24c23086 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -1,5 +1,5 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' import { CallToolRequestSchema, type CallToolResult, @@ -166,16 +166,6 @@ function createError(id: RequestId, code: ErrorCode | number, message: string): } } -function normalizeRequestHeaders(request: NextRequest): HeaderMap { - const headers: HeaderMap = {} - - request.headers.forEach((value, key) => { - headers[key.toLowerCase()] = value - }) - - return headers -} - function readHeader(headers: HeaderMap | undefined, name: string): string | undefined { if (!headers) return undefined const value = headers[name.toLowerCase()] @@ -185,190 +175,6 @@ function readHeader(headers: HeaderMap | undefined, name: string): string | unde return value } -class NextResponseCapture { - private _status = 200 - private _headers = new Headers() - private _controller: ReadableStreamDefaultController | null = null - private _pendingChunks: Uint8Array[] = [] - private _closeHandlers: Array<() => void> = [] - private _errorHandlers: Array<(error: Error) => void> = [] - private _headersWritten = false - private _ended = false - private _headersPromise: Promise - private _resolveHeaders: (() => void) | null = null - private _endedPromise: Promise - private _resolveEnded: (() => void) | null = null - readonly readable: ReadableStream - - constructor() { - this._headersPromise = new Promise((resolve) => { - this._resolveHeaders = resolve - }) - - this._endedPromise = new Promise((resolve) => { - this._resolveEnded = resolve - }) - - this.readable = new ReadableStream({ - start: (controller) => { - this._controller = controller - if (this._pendingChunks.length > 0) { - for (const chunk of this._pendingChunks) { - controller.enqueue(chunk) - } - this._pendingChunks = [] - } - }, - cancel: () => { - this._ended = true - this._resolveEnded?.() - this.triggerCloseHandlers() - }, - }) - } - - private markHeadersWritten(): void { - if (this._headersWritten) return - this._headersWritten = true - this._resolveHeaders?.() - } - - private triggerCloseHandlers(): void { - for (const handler of this._closeHandlers) { - try { - handler() - } catch (error) { - this.triggerErrorHandlers(toError(error)) - } - } - } - - private triggerErrorHandlers(error: Error): void { - for (const errorHandler of this._errorHandlers) { - errorHandler(error) - } - } - - private normalizeChunk(chunk: unknown): Uint8Array | null { - if (typeof chunk === 'string') { - return new TextEncoder().encode(chunk) - } - - if (chunk instanceof Uint8Array) { - return chunk - } - - if (chunk === undefined || chunk === null) { - return null - } - - return new TextEncoder().encode(String(chunk)) - } - - writeHead(status: number, headers?: Record): this { - this._status = status - - if (headers) { - Object.entries(headers).forEach(([key, value]) => { - if (Array.isArray(value)) { - this._headers.set(key, value.join(', ')) - } else { - this._headers.set(key, String(value)) - } - }) - } - - this.markHeadersWritten() - return this - } - - flushHeaders(): this { - this.markHeadersWritten() - return this - } - - write(chunk: unknown): boolean { - const normalized = this.normalizeChunk(chunk) - if (!normalized) return true - - this.markHeadersWritten() - - if (this._controller) { - try { - this._controller.enqueue(normalized) - } catch (error) { - this.triggerErrorHandlers(toError(error)) - } - } else { - this._pendingChunks.push(normalized) - } - - return true - } - - end(chunk?: unknown): this { - if (chunk !== undefined) this.write(chunk) - this.markHeadersWritten() - if (this._ended) return this - - this._ended = true - this._resolveEnded?.() - - if (this._controller) { - try { - this._controller.close() - } catch (error) { - this.triggerErrorHandlers(toError(error)) - } - } - - this.triggerCloseHandlers() - - return this - } - - async waitForHeaders(timeoutMs = 30000): Promise { - if (this._headersWritten) return - - await Promise.race([ - this._headersPromise, - new Promise((resolve) => { - setTimeout(resolve, timeoutMs) - }), - ]) - } - - async waitForEnd(timeoutMs = 30000): Promise { - if (this._ended) return - - await Promise.race([ - this._endedPromise, - new Promise((resolve) => { - setTimeout(resolve, timeoutMs) - }), - ]) - } - - on(event: 'close' | 'error', handler: (() => void) | ((error: Error) => void)): this { - if (event === 'close') { - this._closeHandlers.push(handler as () => void) - } - - if (event === 'error') { - this._errorHandlers.push(handler as (error: Error) => void) - } - - return this - } - - toNextResponse(): NextResponse { - return new NextResponse(this.readable, { - status: this._status, - headers: this._headers, - }) - } -} - function buildMcpServer(abortSignal?: AbortSignal): Server { const server = new Server( { @@ -503,29 +309,17 @@ function buildMcpServer(abortSignal?: AbortSignal): Server { async function handleMcpRequestWithSdk( request: NextRequest, parsedBody: unknown -): Promise { +): Promise { const server = buildMcpServer(request.signal) - const transport = new StreamableHTTPServerTransport({ + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }) - const responseCapture = new NextResponseCapture() - const requestAdapter = { - method: request.method, - headers: normalizeRequestHeaders(request), - } - await server.connect(transport) try { - await transport.handleRequest(requestAdapter as any, responseCapture as any, parsedBody) - await responseCapture.waitForHeaders() - // Must exceed the longest possible tool execution. - // Using ORCHESTRATION_TIMEOUT_MS + 60 s buffer so the orchestrator can - // finish or time-out on its own before the transport is torn down. - await responseCapture.waitForEnd(ORCHESTRATION_TIMEOUT_MS + 60_000) - return responseCapture.toNextResponse() + return await transport.handleRequest(request, { parsedBody }) } finally { await server.close().catch(() => {}) await transport.close().catch(() => {}) @@ -567,6 +361,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return await handleMcpRequestWithSdk(request, parsedBody) } catch (error) { + if (request.signal.aborted || (error as Error)?.name === 'AbortError') { + return NextResponse.json( + createError(0, ErrorCode.ConnectionClosed, 'Client cancelled request'), + { status: 499 } + ) + } + logger.error('Error handling MCP request', { error }) return NextResponse.json(createError(0, ErrorCode.InternalError, 'Internal error'), { status: 500, From dc20229954680726822976f86b4073ea3efe09e6 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 28 Apr 2026 13:06:51 -0700 Subject: [PATCH 13/28] perf(docker): use turbo prune for app.Dockerfile (#4322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(docker): refactor app.Dockerfile to use turbo prune Replaces manual workspace package.json copies with `turbo prune sim --docker`, matching the canonical Vercel/Turborepo monorepo pattern (and the existing realtime.Dockerfile). - New `pruner` stage emits `out/json` (manifests + lockfile) and `out/full` (sources) for only the packages sim actually depends on. - `deps` stage installs from the pruned manifest, so cache invalidates only when package.json/bun.lock change — not on source edits. - Drops 24 lines of brittle manual COPYs (one per workspace package). - Single full install in deps (no --omit=dev) so build-time devDeps like tailwindcss/postcss are available — replaces the earlier hotfix that did a second install in the builder stage. Co-Authored-By: Claude Opus 4.7 * chore(docker): pin turbo to 2.9.6 in pruner stage Match the version locked in package.json so pruner output is reproducible across builds. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- docker/app.Dockerfile | 83 +++++++++++++------------------------------ 1 file changed, 24 insertions(+), 59 deletions(-) diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 1db008a0ab2..766b0eba923 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -11,44 +11,35 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y nodejs +# ======================================== +# Pruner Stage: Emit a minimal monorepo subset that sim depends on +# ======================================== +FROM base AS pruner +WORKDIR /app + +RUN bun install -g turbo@2.9.6 + +COPY . . + +RUN turbo prune sim --docker + # ======================================== # Dependencies Stage: Install Dependencies # ======================================== FROM base AS deps WORKDIR /app -COPY package.json bun.lock turbo.json ./ -RUN mkdir -p apps \ - packages/audit \ - packages/db \ - packages/logger \ - packages/realtime-protocol \ - packages/security \ - packages/testing \ - packages/tsconfig \ - packages/utils \ - packages/workflow-authz \ - packages/workflow-persistence \ - packages/workflow-types -COPY apps/sim/package.json ./apps/sim/package.json -COPY packages/audit/package.json ./packages/audit/package.json -COPY packages/db/package.json ./packages/db/package.json -COPY packages/logger/package.json ./packages/logger/package.json -COPY packages/realtime-protocol/package.json ./packages/realtime-protocol/package.json -COPY packages/security/package.json ./packages/security/package.json -COPY packages/testing/package.json ./packages/testing/package.json -COPY packages/tsconfig/package.json ./packages/tsconfig/package.json -COPY packages/utils/package.json ./packages/utils/package.json -COPY packages/workflow-authz/package.json ./packages/workflow-authz/package.json -COPY packages/workflow-persistence/package.json ./packages/workflow-persistence/package.json -COPY packages/workflow-types/package.json ./packages/workflow-types/package.json - -# Install dependencies, then rebuild isolated-vm for Node.js -# Use --linker=hoisted for flat node_modules layout (required for Docker multi-stage builds) -# JOBS=4 caps node-gyp parallelism — higher values OOM isolated-vm (laverdet/isolated-vm#428) +# Pruned manifests + lockfile from the pruner stage. This layer only invalidates +# when package.json/bun.lock content changes — not on source edits. +COPY --from=pruner /app/out/json/ ./ +COPY --from=pruner /app/out/bun.lock ./bun.lock + +# Install all dependencies (including devDependencies — tailwindcss/postcss are +# devDeps but required at build time). Then rebuild isolated-vm against Node.js. +# JOBS=4 caps node-gyp parallelism — higher values OOM isolated-vm (laverdet/isolated-vm#428). RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \ --mount=type=cache,id=npm-cache,target=/root/.npm \ - HUSKY=0 bun install --omit=dev --ignore-scripts --linker=hoisted && \ + HUSKY=0 bun install --ignore-scripts --linker=hoisted && \ cd node_modules/isolated-vm && JOBS=4 npx node-gyp rebuild --release # ======================================== @@ -58,37 +49,11 @@ FROM base AS builder ARG TARGETPLATFORM WORKDIR /app -# Install turbo globally (cached for fast reinstall) -RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \ - bun install -g turbo - # Copy node_modules from deps stage (cached if dependencies don't change) COPY --from=deps /app/node_modules ./node_modules -# Copy package configuration files (needed for build) -COPY package.json bun.lock turbo.json ./ -COPY apps/sim/package.json ./apps/sim/package.json -COPY packages/audit/package.json ./packages/audit/package.json -COPY packages/db/package.json ./packages/db/package.json -COPY packages/logger/package.json ./packages/logger/package.json -COPY packages/realtime-protocol/package.json ./packages/realtime-protocol/package.json -COPY packages/security/package.json ./packages/security/package.json -COPY packages/testing/package.json ./packages/testing/package.json -COPY packages/tsconfig/package.json ./packages/tsconfig/package.json -COPY packages/utils/package.json ./packages/utils/package.json -COPY packages/workflow-authz/package.json ./packages/workflow-authz/package.json -COPY packages/workflow-persistence/package.json ./packages/workflow-persistence/package.json -COPY packages/workflow-types/package.json ./packages/workflow-types/package.json - -# Copy workspace configuration files (needed for turbo) -COPY apps/sim/next.config.ts ./apps/sim/next.config.ts -COPY apps/sim/tsconfig.json ./apps/sim/tsconfig.json -COPY apps/sim/tailwind.config.ts ./apps/sim/tailwind.config.ts -COPY apps/sim/postcss.config.mjs ./apps/sim/postcss.config.mjs - -# Copy source code (changes most frequently - placed last to maximize cache hits) -COPY apps/sim ./apps/sim -COPY packages ./packages +# Copy pruned source tree (apps/sim + workspace packages it depends on) +COPY --from=pruner /app/out/full/ ./ ENV NEXT_TELEMETRY_DISABLED=1 \ VERCEL_TELEMETRY_DISABLED=1 \ @@ -164,4 +129,4 @@ EXPOSE 3000 ENV PORT=3000 \ HOSTNAME="0.0.0.0" -CMD ["bun", "apps/sim/server.js"] \ No newline at end of file +CMD ["bun", "apps/sim/server.js"] From 6a7d5ae8287d230d31aea8d8868db4153a7d4b87 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 28 Apr 2026 13:07:24 -0700 Subject: [PATCH 14/28] feat(files): extract PDF viewer behind SSR boundary and polish file preview (#4316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(files): extract PDF viewer behind SSR boundary and polish file preview ## Core architectural fix Move all react-pdf / pdfjs-dist code into a new pdf-viewer.tsx module and import it exclusively via next/dynamic({ ssr: false }). pdfjs-dist v5 references DOMMatrix at module evaluation time, which crashed SSR. The previous workaround (a DOMMatrix polyfill in instrumentation.ts) is removed in favour of this proper hard module boundary. ## PDF viewer improvements - Cursor-anchored zoom: Ctrl/⌘+wheel and trackpad-pinch now zoom toward the cursor instead of the top-left corner. Toolbar ± buttons anchor to the viewport centre. Uses the canonical scroll-adjust formula used by map and canvas viewers. - Horizontal scroll: dropping flex-col from the scroll container lets the zoomed pages wrapper overflow naturally and produces a horizontal scrollbar at zoom > 1×. - Loading skeleton: replaced the conditional inline skeleton with an absolute inset-0 overlay so it fills the scroll container correctly in all layout contexts. - Shadow tokens: fixed shadow-[var(--shadow-medium)] and shadow-[var(--shadow-card)] to use the Tailwind utility classes shadow-medium and shadow-card directly. ## File viewer cleanup - data-table.tsx: wrap setInputRef in useCallback([]) so the ref callback has a stable identity across renders. Previously the inline function got a new identity on every keystroke (because editValue state changed), causing React to teardown/remount the ref and re-run node.select() on every character typed. - preview-panel.tsx: keep useMemo on ctxValue passed to Context.Provider — Context uses Object.is, so a new object every render causes unnecessary consumer re-renders. - resource-content.tsx: remove unnecessary useCallback/useMemo wrappers on handlers and derived values that have no memoization observers. ## API route - Wrap content route with withRouteHandler for automatic request-ID tracking via AsyncLocalStorage; remove manual generateRequestId() calls. - Add resourceName to audit record; add encoding param support (base64 / utf-8). ## Query hooks - Include key (storage object key) in both useWorkspaceFileContent and useWorkspaceFileBinary query key tuples so the cache is correctly busted when a file is re-uploaded with a new storage key. ## Other - Add Suspense boundaries to files/page.tsx and files/[fileId]/page.tsx (required for useSearchParams inside the Files component). - Add mmd to SUPPORTED_CODE_EXTENSIONS (Mermaid diagrams). - Add https: to CSP img-src. - Remove ==== separator comments from lib/copilot/constants.ts. - New dependencies: pdfjs-dist 5.4.296, mermaid 11.14.0, monaco-editor 0.55.1, @monaco-editor/react 4.7.0. * fix(files): replace instanceof Error checks with toError() and fix skeleton tokens - Use toError() from @sim/utils/errors across all catch blocks in file-viewer.tsx, preview-panel.tsx, and route.ts instead of the prohibited `err instanceof Error ? err.message : fallback` pattern - Fix loading skeleton in files.tsx: bg-white → bg-[var(--surface-2)] and shadow-[var(--shadow-medium)] → shadow-medium * fix(files): address PR review findings - csp.ts: revert bare https: from img-src — it defeats the existing domain allowlist and opens info-leakage vectors - files/page.tsx + files/[fileId]/page.tsx: add explicit fallback={null} to to make intent clear (React defaults to null, but omitting it looks like an oversight) - preview-panel.tsx: restore pre passthrough in STATIC_MARKDOWN_COMPONENTS so Streamdown's wrapping
 doesn't nest inside the custom code
  block 
, which produced invalid HTML and broken styling - file-viewer.tsx: add 'webm' to VIDEO_PREVIEWABLE_EXTENSIONS to match 'video/webm' in VIDEO_PREVIEWABLE_MIME_TYPES * chore(files): revert accidental pptxgenjs.cjs re-minification The bundle was regenerated non-deterministically during development (same pptxgenjs 4.0.1, different variable names in minifier output). No functional change — restore the prior version to keep the diff clean. * fix(files): fix Monaco stale closure, XLSX Ctrl+S data loss, and async workbook mutation Three bugs from Cursor Bugbot follow-up review: 1. Stale closure in handleEditorMount (Medium): useCallback([], []) captured content='' at first render. When Monaco mounts after content loads (e.g. switching from preview to editor mode), lastSyncedContentRef was never initialized and external content changes stopped syncing. Fixed by keeping a contentRef updated on every render and reading it inside handleEditorMount. 2. XLSX Ctrl+S discards active cell edit (Medium): handleSave read from workbookRef.current before DataTable's in-progress editValue was committed. Fixed by exposing commitEdit() from DataTable via useImperativeHandle (using an always-current editStateRef so the handle stays stable) and calling it at the top of handleSave. 3. Async workbook mutation fragility (Low): handleCellChange / handleHeaderChange updated the workbook inside import('xlsx').then(), creating microtask-order coupling with handleSave. Fixed by caching the xlsx module in xlsxModuleRef on first parse and using it synchronously in both handlers. * refactor(files): cleanup anti-patterns across file viewer components Six-pass cleanup over the file-viewer directory: Effects (you-might-not-need-an-effect): - AudioPreview, VideoPreview: replace reset useEffect with key={file.id} so the component remounts on file change — React's canonical solution - DocxPreview: same key-prop fix; removes a 5-setState reset effect that was also clearing containerRef.current.innerHTML unnecessarily Callbacks (you-might-not-need-a-callback): - handleEditorMount, handleEditorChange: remove useCallback — MonacoEditor is dynamic(), not React.memo, so reference stability has no observer - markSavedContent: remove useCallback — called only through an onSaveRef, never directly observed - DataTable.setInputRef: remove useCallback — callback refs on native elements are called regardless of reference identity Design tokens (emcn-design-review): - VideoPreview: bg-black → bg-[var(--surface-inverted)] - HtmlPreview iframe: bg-white → bg-[var(--surface-2)] useMemo, useState, and react-query passes found no issues. * improvement(files): replace stock Monaco theme with Sim design system theme Define sim-dark and sim-light Monaco themes using Sim's exact design tokens instead of the default vs/vs-dark which looked identical to stock VSCode. Chrome changes (both themes): - Background, gutter use --bg (not VSCode's near-black / pure-white defaults) - Line numbers use --text-muted instead of VSCode gray - Cursor switches to --brand-secondary (#33b4ff) - Selection highlight is brand blue at 15% opacity - Scrollbar shadow removed, track uses surface tokens - Bracket match, word highlight, find match all keyed to brand blue - Suggestion/hover widgets use --surface-2 / --border tokens - All hardcoded shadows removed (scrollbar.shadow = transparent) Syntax token changes (inherit: true — base handles unlisted tokens): - Comments: muted gray + italic (vs VSCode's bright green) - Strings: #3ab872 dark / #16825d light (vs VSCode orange-red) - Numbers: warm amber / warm orange (both readable on their backgrounds) - Keywords: #33b4ff dark / #0078d4 light (brand blue family) - Types: complementary blue-gray / purple * fix(files): bump light theme comment color to #888888 for WCAG contrast * fix(files): fix dark mode comment contrast #4a4a4a → #606060 (~1.9:1 → ~2.9:1) * improvement(files): cursor to default color, video background to surface-1 - Monaco cursor: #33b4ff (brand blue) → #e6e6e6 dark / #1a1a1a light (text cursor should be neutral, not loud) - VideoPreview background: var(--surface-inverted) → var(--surface-1) (consistent with PDF viewer, fits workspace context over cinema-black) * fix(files): stabilize setInputRef callback and guard against double-commit in DataTable Wrap setInputRef in useCallback([], []) so React doesn't tear down and re-mount the input ref on every keystroke. Without stable identity, every editValue state change caused node.focus()/node.select() to fire, resetting the cursor selection to "select all" on each character typed. Add isCommittedRef to guard both the imperative commitEdit handle and the inline commitEdit (called by onBlur) against double-application. The ref is cleared in startEdit and set to true on the first commit, so a concurrent onBlur cannot re-apply the same edit. * fix(files): preserve scroll position during Mothership streaming edits Two fixes to the Monaco auto-scroll logic: 1. At streaming start, initialize textareaStuckRef from the editor's actual scroll position (isAtBottom check) instead of unconditionally setting true. Previously every streaming session jumped the viewport to the last line on the very first content update, even when the user was reading mid-file. 2. Replace the wheel-only DOM listener with editor.onDidScrollChange(), the proper Monaco API. This covers trackpad, scrollbar drag, and keyboard scroll — not just mouse wheel. As a bonus, scrolling back to the bottom during streaming now re-engages follow mode (matching iTerm2/xterm.js behavior). 3. Save and restore view state around model.setValue() during streaming when the user has scrolled away from the bottom. This prevents Monaco from resetting the viewport on each content replacement. When the user is at the bottom, view state is not saved so Effect 3 can scroll to the new bottom. * fix(files): fix two scroll logic bugs introduced in previous streaming scroll fix The prior fix introduced a regression for the "user was at bottom" case and a false-disengagement bug from programmatic scroll events. Bug 1 — Effect ordering: all three effects fire on the same render when isStreamInteractionLocked flips true. Effect 2 called isAtBottom() AFTER Effect 1 had already called model.setValue(), which grew scrollHeight. The old "at bottom" scroll position was now 200px short of the new bottom, so isAtBottom() returned false, textareaStuckRef was set false, and Effect 3 never called revealLine. Users at the bottom stopped following the stream. Fix: measure isAtBottom() in Effect 1 BEFORE setValue, while scrollHeight is still accurate. Set textareaStuckRef = true only (never false here). Effect 2 no longer initializes the ref — only the listener disengages it. Bug 2 — onDidScrollChange fires during model.setValue: Monaco fires onDidScrollChange when scroll dimensions change, including when setValue grows the document. This caused the listener to disengage auto-scroll on every content update even with no user interaction. Fix: add suppressScrollListenerRef, set true before setValue/restoreViewState and false after. The listener exits early when suppressed, so only genuine user scroll events (wheel, trackpad, keyboard, scrollbar) can disengage. Both refs moved to the component's ref block for conventional placement. * chore(files): remove extraneous comments from file viewer and data table * refactor(files): split 2281-line file-viewer.tsx into focused modules TextEditor, DocxPreview, PptxPreview, XlsxPreview, ImagePreview each moved to their own files. Shared utilities (PreviewError, resolvePreviewError, shouldSuppressStreamingDocumentError, PDF_PAGE_SKELETON) extracted to preview-shared.tsx. file-viewer.tsx is now the orchestrator + MIME constants + small stateless previews (~495 lines). * fix(files): remove unnecessary TextEditorProps export * refactor(files): four stellar-quality improvements to file-viewer split - Extract useBlobUrl hook shared by AudioPreview and VideoPreview, eliminating ~30 lines of duplicated state/effect logic - Stabilize markSavedContent with useCallback (matches setDraftContent) - Stabilize handleEditorChange with useCallback([setDraftContent]) - Fix pptx static render effect deps: drop redundant dataUpdatedAt (already encoded in cacheKey) and unused workspaceId * test(files): extract pure modules and add 122-test suite for file viewer logic Extract TextEditorContentState machine and file category resolution into plain .ts modules (text-editor-state.ts, file-category.ts) so they can be unit-tested without React or Next.js overhead. Update component files to import from the extracted modules, eliminating code duplication. Add two test files: - text-editor-state.test.ts: 32 tests covering resolveStreamingEditorContent, the reducer (edit / save-success), and syncTextEditorContentState across all phases (uninitialized, ready, streaming, reconciling) including reference-equality short-circuit checks for zero-allocation paths - file-category.test.ts: 90 tests covering MIME-type routing for all 8 categories, extension fallback, MIME-priority-over-extension, and case-insensitive extension handling * fix(files): add key to IframePreview and use monotonic seq for streaming PDF key - Add key={file.id} to IframePreview so React remounts on file switch, preventing stale renderError from persisting across different files - Replace key={streamingBuffer.byteLength} with a monotonic sequence counter so same-size successive PDF compilations still trigger a remount * fix(files): restore getFileExtension import dropped during refactor * fix(files): clear loadError on PDF success and fix streaming null-flash - pdf-viewer: add setLoadError(null) in onLoadSuccess so the toolbar is not permanently hidden after a failed-then-successful PDF load - file-viewer: consolidate streaming-mode rendering so the debounce period (before rendering=true) shows a skeleton instead of null * refactor(files): cleanup pass — effect, callback, state, and design fixes - text-editor: replace sync-external useEffect with "adjust during render" pattern so the state machine advances immediately instead of after a paint - text-editor: remove unnecessary useCallback from markSavedContent (no observer) - files: narrow deleteTargetFile state to {id, name} — only those fields are used - files: remove uploadFile (mutation object) from useCallback deps — .mutateAsync is stable - files: remove unnecessary useCallback from handleNavigateToFiles (no observer) - files: replace raw