diff --git a/.agents/skills/react-query-best-practices/SKILL.md b/.agents/skills/react-query-best-practices/SKILL.md index fd8da063632..8a595d1b4dc 100644 --- a/.agents/skills/react-query-best-practices/SKILL.md +++ b/.agents/skills/react-query-best-practices/SKILL.md @@ -31,7 +31,7 @@ Read these before analyzing: ### Query hooks - Every `queryFn` must forward `signal` for request cancellation -- Every query must have an explicit `staleTime` (default 0 is almost never correct) +- Every query must have an explicit `staleTime` (default 0 is almost never correct), assigned from a named exported constant — never an inline numeric literal. A server-side prefetch hydrating the same query key must import and reuse that constant instead of restating the number - `keepPreviousData` / `placeholderData` only on variable-key queries (where params change), never on static keys - Use `enabled` to prevent queries from running without required params diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md index f5990517573..7e8df25695a 100644 --- a/.claude/commands/add-trigger.md +++ b/.claude/commands/add-trigger.md @@ -374,7 +374,7 @@ export const {service}PollingHandler: PollingProviderHandler = { try { // For OAuth services: - const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId, logger) + const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId) const config = webhookData.providerConfig as unknown as {Service}WebhookConfig // First poll: seed state, emit nothing @@ -421,7 +421,7 @@ export const {service}PollingTrigger: TriggerConfig = { polling: true, // REQUIRED — routes to polling infrastructure subBlocks: [ - { id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger', supportsCredentialSets: true }, + { id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger' }, // ... service-specific config fields (dropdowns, inputs, switches) ... { id: 'triggerInstructions', type: 'text', title: 'Setup Instructions', hideFromPreview: true, mode: 'trigger', defaultValue: '...' }, ], diff --git a/.claude/commands/react-query-best-practices.md b/.claude/commands/react-query-best-practices.md index 22bdc320dde..0fc450958bf 100644 --- a/.claude/commands/react-query-best-practices.md +++ b/.claude/commands/react-query-best-practices.md @@ -31,7 +31,7 @@ Read these before analyzing: ### Query hooks - Every `queryFn` must forward `signal` for request cancellation -- Every query must have an explicit `staleTime` (default 0 is almost never correct) +- Every query must have an explicit `staleTime` (default 0 is almost never correct), assigned from a named exported constant — never an inline numeric literal. A server-side prefetch hydrating the same query key must import and reuse that constant instead of restating the number - `keepPreviousData` / `placeholderData` only on variable-key queries (where params change), never on static keys - Use `enabled` to prevent queries from running without required params diff --git a/.claude/rules/sim-queries.md b/.claude/rules/sim-queries.md index d1db9437ff0..14acca8f2cb 100644 --- a/.claude/rules/sim-queries.md +++ b/.claude/rules/sim-queries.md @@ -34,7 +34,7 @@ Next.js rewrites **every** export of a `'use client'` module into a *client refe So any **query-key factory, standalone `requestJson` fetcher, mapper, or constant** that a server module imports must live in a **non-`'use client'`** module: - key factories → `hooks/queries/utils/-keys.ts` (see `folder-keys.ts`, `table-keys.ts`, `credential-keys.ts`) -- standalone fetchers/mappers → `hooks/queries/utils/fetch-*.ts` / `*-list-query.ts` (see `fetch-workflow-envelope.ts`, `fetch-credential-set.ts`) +- standalone fetchers/mappers → `hooks/queries/utils/fetch-*.ts` / `*-list-query.ts` (see `fetch-workflow-envelope.ts`, `fetch-workspace-credentials.ts`) The `'use client'` hook module then imports these back for its hooks. **Never** define a server-imported factory/fetcher directly in a `'use client'` hooks file — it crashes SSR (this caused the tables-page crash). Enforced for prefetch/route/trigger/block files by `scripts/check-client-boundary-imports.ts` (`bun run check:client-boundary`, run in CI). Escape hatch for a genuinely browser-only path: `// client-boundary-allow: ` on the line above the import. @@ -50,7 +50,7 @@ The `'use client'` hook module then imports these back for its hooks. **Never** ## Query Hook - Every `queryFn` must destructure and forward `signal` for request cancellation -- Every query must have an explicit `staleTime` +- Every query must have an explicit `staleTime`, assigned from a named exported constant (`ENTITY_LIST_STALE_TIME`), never an inline numeric literal. A server-side prefetch (`prefetch.ts`) hydrating the same query key must import and reuse that constant, not restate the number — this is what keeps a prefetched cache entry from going stale out of sync with the client hook that reads it - Use `keepPreviousData` only on variable-key queries (where params change), never on static keys - Same-origin JSON calls must go through `requestJson(contract, ...)` from `@/lib/api/client/request` against the contract in `@/lib/api/contracts/**` @@ -58,6 +58,8 @@ The `'use client'` hook module then imports these back for its hooks. **Never** import { requestJson } from '@/lib/api/client/request' import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities' +export const ENTITY_LIST_STALE_TIME = 60 * 1000 + async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise { const data = await requestJson(listEntitiesContract, { query: { workspaceId }, @@ -71,7 +73,7 @@ export function useEntityList(workspaceId?: string, options?: { enabled?: boolea queryKey: entityKeys.list(workspaceId), queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal), enabled: Boolean(workspaceId) && (options?.enabled ?? true), - staleTime: 60 * 1000, + staleTime: ENTITY_LIST_STALE_TIME, placeholderData: keepPreviousData, // OK: workspaceId varies }) } diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index f2e7a5f63b5..154b45a2599 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -96,6 +96,55 @@ Adding a new settings page: `settings/[section]/settings.tsx`. 3. Build the component body inside `` — no shell, no title block. +## Text-scale tokens (no literal pixel sizes) + +Settings pages never use a literal `text-[Npx]` class — always the named Tailwind +scale token from `apps/sim/tailwind.config.ts`'s `fontSize` extension (`text-micro` +10px, `text-xs` 11px, `text-caption` 12px, `text-small` 13px, `text-sm` 14px +[Tailwind default, unmodified], `text-base` 15px, `text-md` 16px, `text-lg` 18px +[Tailwind default]). A literal size is either a straight rename to the equivalent +token (if the pixel value matches one exactly) or a sign the page never migrated — +grep `text-\[1[0-8]px\]` under `apps/sim/app/workspace/*/settings/**` and +`apps/sim/ee/**` to find stragglers. + +For a two-line list row (title/value on top, a muted subtitle below — a name + +email, a tool name + description, a server name + status), the established +pairing is: + +- **Title / row value**: `text-[var(--text-body)] text-sm` +- **Subtitle / muted description**: `text-[var(--text-muted)] text-caption` + +This is not a stylistic guess — it is the tokenized form of the literal-pixel +pairing (`text-[14px] text-[var(--text-body)]` / `text-[12px] +text-[var(--text-muted)]`) already used for this exact row shape across +`member-list.tsx`, `api-keys.tsx`, `mcp.tsx`, `billing.tsx`, +`workflow-mcp-servers.tsx`, and others — keep new rows consistent with it rather +than inventing a new size pairing. + +For a toggle row (a `Switch` with a title and optional description), use the emcn +`Label` component for the title — never a hand-rolled `` — paired with +`Switch`'s `id`/`Label`'s `htmlFor`: + +```tsx +
+
+ +

One-line description.

+
+ +
+``` + +`Label`'s own default styling (`font-medium text-[var(--text-primary)] +text-small`) already matches the established title treatment — do not add a +`className` overriding its size/color unless the row genuinely needs something +different. + +`--text-primary`/`--text-secondary` and `--text-body`/`--text-muted` are both real, +independently-defined tokens (not interchangeable — they resolve to different +colors) and both see legitimate use across settings pages; this rule only pins +down the **row title/subtitle** shape above, not every text element on every page. + ## Other shared settings primitives (do not re-roll these) - **`SettingsEmptyState`** (`…/components/settings-empty-state`) — the canonical @@ -154,7 +203,7 @@ changes" modal: ## Detail sub-views A drill-down view reached from a list row (selected MCP server, workflow MCP -server, credential set, permission group, retention policy) renders through +server, permission group, retention policy) renders through `SettingsPanel` like a list page: pass `back={{ text, icon: ArrowLeft, onSelect }}` for the left back chip, `title` (the entity name), and the header `actions`, then render the body. Do NOT hand-roll a shell or header bar; a tab bar renders as the @@ -175,4 +224,5 @@ A settings page is design-system-clean when: - [ ] Detail sub-views and entitlement/loading gates keep their own chrome (intentional). - [ ] If it has editable state: Save/Discard go through `SaveDiscardActions`, dirty is wired via `useSettingsUnsavedGuard` (called before any early-return gate), and there is **no** hand-rolled Save button / `beforeunload` / "Unsaved changes" modal. - [ ] No business logic, handlers, or conditional rendering changed by the migration. +- [ ] No literal `text-[Npx]` classes — named scale tokens only (see "Text-scale tokens" above). - [ ] `tsc`, `biome`, and the page's tests pass. diff --git a/.claude/skills/add-settings-page/SKILL.md b/.claude/skills/add-settings-page/SKILL.md index 2da63f9a08e..69173332ad4 100644 --- a/.claude/skills/add-settings-page/SKILL.md +++ b/.claude/skills/add-settings-page/SKILL.md @@ -50,21 +50,30 @@ For each page component, confirm the checklist in `.claude/rules/sim-settings-pa **gate** early-return. Anything else is a page that still needs migrating. 2. Find hand-rolled title blocks (should be 0 outside detail views): `git grep -n "text-\[var(--text-body)\] text-lg" -- 'apps/sim/**/settings/' 'apps/sim/ee/'` -3. Confirm each page imports `SettingsPanel` and that its `NavigationItem` has an +3. Find literal pixel text sizes (should be 0 — see "Text-scale tokens" in + `.claude/rules/sim-settings-pages.md` for the token map and the row + title/subtitle pairing convention): + `git grep -n "text-\[1[0-8]px\]" -- 'apps/sim/**/settings/' 'apps/sim/ee/'` +4. Confirm each page imports `SettingsPanel` and that its `NavigationItem` has an accurate `description` of consistent length with its peers. - Editable pages: confirm Save/Discard go through `SaveDiscardActions` and dirty is wired via `useSettingsUnsavedGuard` (called before early-return gates) — flag any hand-rolled Save button, `beforeunload`, or unsaved modal. `git grep -n "beforeunload" -- 'apps/sim/**/settings/' 'apps/sim/ee/'` should only hit the centralized `use-settings-before-unload.ts`. -4. When migrating a page, change ONLY the structural shell→`SettingsPanel` swap: +5. When migrating a page, change ONLY the structural shell→`SettingsPanel` swap: move header chips to `actions`, the standalone search to `search`, delete the `

` title block, replace the three closing `` (column/scroll/shell) with ``, and keep modal siblings in a `<>` fragment. Do NOT touch handlers, state, queries, conditional rendering, or detail/gate returns. Drop per-page `gap-*`/`pt-*` on the content column in favor of the panel default. -5. Remove now-unused imports (`ChipInput`/`Search`) ONLY after grepping that +6. When fixing literal pixel text sizes, replace ONLY the size class with its + exact-pixel-equivalent named token (e.g. `text-[12px]` → `text-caption`, + never a different size) — this must render pixel-identical, not restyle the + page. Leave color tokens (`--text-primary` vs `--text-body`, etc.) untouched + unless they're also being changed for an unrelated, deliberate reason. +7. Remove now-unused imports (`ChipInput`/`Search`) ONLY after grepping that they are not still used elsewhere in the file (e.g. by a detail view). -6. **Verify the whole sweep:** `tsc --noEmit`, `biome check` on every touched +8. **Verify the whole sweep:** `tsc --noEmit`, `biome check` on every touched file, and run the affected pages' tests. Diff each file against the base and confirm the change is purely structural before shipping. diff --git a/.cursor/commands/add-trigger.md b/.cursor/commands/add-trigger.md index 6e1e6ed975f..bcf54ec6587 100644 --- a/.cursor/commands/add-trigger.md +++ b/.cursor/commands/add-trigger.md @@ -369,7 +369,7 @@ export const {service}PollingHandler: PollingProviderHandler = { try { // For OAuth services: - const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId, logger) + const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId) const config = webhookData.providerConfig as unknown as {Service}WebhookConfig // First poll: seed state, emit nothing @@ -416,7 +416,7 @@ export const {service}PollingTrigger: TriggerConfig = { polling: true, // REQUIRED — routes to polling infrastructure subBlocks: [ - { id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger', supportsCredentialSets: true }, + { id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger' }, // ... service-specific config fields (dropdowns, inputs, switches) ... { id: 'triggerInstructions', type: 'text', title: 'Setup Instructions', hideFromPreview: true, mode: 'trigger', defaultValue: '...' }, ], diff --git a/.cursor/commands/react-query-best-practices.md b/.cursor/commands/react-query-best-practices.md index 6aced33b7b2..420f52a2c93 100644 --- a/.cursor/commands/react-query-best-practices.md +++ b/.cursor/commands/react-query-best-practices.md @@ -26,7 +26,7 @@ Read these before analyzing: ### Query hooks - Every `queryFn` must forward `signal` for request cancellation -- Every query must have an explicit `staleTime` (default 0 is almost never correct) +- Every query must have an explicit `staleTime` (default 0 is almost never correct), assigned from a named exported constant — never an inline numeric literal. A server-side prefetch hydrating the same query key must import and reuse that constant instead of restating the number - `keepPreviousData` / `placeholderData` only on variable-key queries (where params change), never on static keys - Use `enabled` to prevent queries from running without required params diff --git a/CLAUDE.md b/CLAUDE.md index df1f7fb9826..a0632e40ad7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -290,6 +290,8 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities' +export const ENTITY_LIST_STALE_TIME = 60 * 1000 + async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise { const data = await requestJson(listEntitiesContract, { query: { workspaceId }, @@ -303,7 +305,7 @@ export function useEntityList(workspaceId?: string) { queryKey: entityKeys.list(workspaceId), queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal), enabled: Boolean(workspaceId), - staleTime: 60 * 1000, + staleTime: ENTITY_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -326,7 +328,7 @@ export const entityKeys = { ### Query Hooks - Every `queryFn` must forward `signal` for request cancellation -- Every query must have an explicit `staleTime` +- Every query must have an explicit `staleTime`, assigned from a named exported constant, never an inline numeric literal — a server-side prefetch hydrating the same query key must import and reuse that constant so the two never drift out of sync - Use `keepPreviousData` only on variable-key queries (where params change), never on static keys ```typescript @@ -335,7 +337,7 @@ export function useEntityList(workspaceId?: string) { queryKey: entityKeys.list(workspaceId), queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal), enabled: Boolean(workspaceId), - staleTime: 60 * 1000, + staleTime: ENTITY_LIST_STALE_TIME, placeholderData: keepPreviousData, // OK: workspaceId varies }) } diff --git a/apps/docs/content/docs/de/enterprise/index.mdx b/apps/docs/content/docs/de/enterprise/index.mdx index 02ee74cdb34..c217e859e0e 100644 --- a/apps/docs/content/docs/de/enterprise/index.mdx +++ b/apps/docs/content/docs/de/enterprise/index.mdx @@ -79,7 +79,6 @@ Für selbst gehostete Bereitstellungen können Enterprise-Funktionen über Umgeb | Variable | Beschreibung | |----------|-------------| | `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On mit SAML/OIDC | -| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling-Gruppen für E-Mail-Trigger | | `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Workspace-/Organisations-Einladungen global deaktivieren | diff --git a/apps/docs/content/docs/de/triggers/index.mdx b/apps/docs/content/docs/de/triggers/index.mdx index 23b51adf279..48e5a9c7412 100644 --- a/apps/docs/content/docs/de/triggers/index.mdx +++ b/apps/docs/content/docs/de/triggers/index.mdx @@ -33,9 +33,6 @@ Verwende den Start-Block für alles, was aus dem Editor, deploy-to-API oder depl RSS- und Atom-Feeds auf neue Inhalte überwachen - - Team-Gmail- und Outlook-Postfächer überwachen - ## Schneller Vergleich @@ -46,7 +43,6 @@ Verwende den Start-Block für alles, was aus dem Editor, deploy-to-API oder depl | **Schedule** | Timer, der im Schedule-Block verwaltet wird | | **Webhook** | Bei eingehender HTTP-Anfrage | | **RSS Feed** | Neues Element im Feed veröffentlicht | -| **Email Polling Groups** | Neue E-Mail in Team-Gmail- oder Outlook-Postfächern empfangen | > Der Start-Block stellt immer `input`, `conversationId` und `files` Felder bereit. Füge benutzerdefinierte Felder zum Eingabeformat für zusätzliche strukturierte Daten hinzu. diff --git a/apps/docs/content/docs/en/files/editor.mdx b/apps/docs/content/docs/en/files/editor.mdx new file mode 100644 index 00000000000..672797fd98a --- /dev/null +++ b/apps/docs/content/docs/en/files/editor.mdx @@ -0,0 +1,56 @@ +--- +title: Editor +description: A rich markdown editor for your files — type markdown and watch it render, or edit visually. +pageType: concept +--- + +import { Image } from '@/components/ui/image' +import { Card, Cards } from 'fumadocs-ui/components/card' +import { Callout } from 'fumadocs-ui/components/callout' + +Every markdown file in your workspace opens in a **rich editor**. Type markdown and it renders as you go, or format visually with the toolbar and slash menu. What you see is exactly what gets saved — plain markdown underneath, no lock-in. + +
+ A markdown file rendered in the editor: headings, bold, italic, and a link, a nested bullet list, and a table +
+ +## Formatting text + +Select any text to bring up the formatting toolbar — bold, italic, strikethrough, inline code, and links. The same marks appear instantly as you type the markdown for them, like `**bold**` or `*italic*`. Links show a hover card so you can open, copy, edit, or remove them without hunting through the source. + +## Structure + +Headings, blockquotes, and dividers keep long documents scannable. Type `# ` through `###### ` for headings, `> ` for a quote, and `---` for a divider. + +## Lists and checklists + +Bullet, ordered, and nested lists all work, plus task lists you can tick right in the document. + +## Tables + +Insert a table from the slash menu, then click any cell for the floating table toolbar — add or remove rows and columns, toggle the header row, or delete the table. Drag a column border to resize it. + +## Code blocks + +Fenced code blocks are syntax-highlighted, with a language picker in the corner. Pick `mermaid` to render a live diagram instead of code. + +## Images + +Paste or drag an image straight into the document, then drag a corner to resize it. + +## Slash menu and shortcuts + +Type `/` anywhere to insert any block — heading, list, table, code block, image, and more — without leaving the keyboard. Familiar shortcuts work too: **Cmd/Ctrl + B** for bold, **Cmd/Ctrl + I** for italic, and **Cmd/Ctrl + K** to add a link over selected text. + +## Markdown fidelity + +The editor round-trips your markdown exactly — it saves what you wrote, with no reformatting churn. + + +A few constructs can't be represented visually without losing information on save — footnotes, raw HTML, and HTML comments. When a file contains one of these, it opens **read-only** so the original source is preserved untouched. Everything is still rendered faithfully; you just can't edit that file inline. + + + + + + diff --git a/apps/docs/content/docs/en/integrations/ahrefs.mdx b/apps/docs/content/docs/en/integrations/ahrefs.mdx index 92d278e9fab..329f4428fa1 100644 --- a/apps/docs/content/docs/en/integrations/ahrefs.mdx +++ b/apps/docs/content/docs/en/integrations/ahrefs.mdx @@ -18,8 +18,8 @@ With the Ahrefs integration in Sim, you can: - **Analyze Domain Rating & Authority**: Instantly check the Domain Rating (DR) and Ahrefs Rank of any website to gauge its authority. - **Fetch Backlinks**: Retrieve a list of backlinks pointing to a site or specific URL, with details like anchor text, referring page DR, and more. - **Get Backlink Statistics**: Access metrics on backlink types (dofollow, nofollow, text, image, redirect, etc.) for a domain or URL. -- **Explore Organic Keywords** *(planned)*: View keywords a domain ranks for and their positions in Google search results. -- **Discover Top Pages** *(planned)*: Identify the highest-performing pages by organic traffic and links. +- **Explore Organic Keywords**: View keywords a domain ranks for and their positions in Google search results. +- **Discover Top Pages**: Identify the highest-performing pages by organic traffic and links. These tools let your agents automate SEO research, monitor competitors, and generate reports—all as part of your workflow automations. To use the Ahrefs integration, you’ll need an Ahrefs Enterprise subscription with API access. {/* MANUAL-CONTENT-END */} @@ -244,7 +244,7 @@ Get the top pages of a target domain sorted by organic traffic. Returns page URL | --------- | ---- | -------- | ----------- | | `target` | string | Yes | The target domain to analyze. Example: "example.com" | | `country` | string | No | Country code for traffic data. Example: "us", "gb", "de" \(default: "us"\) | -| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\). Example: "domain" | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | | `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) | | `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | | `apiKey` | string | Yes | Ahrefs API Key | @@ -293,4 +293,374 @@ Get detailed metrics for a keyword including search volume, keyword difficulty, | ↳ `branded` | boolean | Query references a specific brand | | ↳ `local` | boolean | Query seeks local results | +### `ahrefs_paid_pages` + +Get a target domain's pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" | +| `country` | string | No | Country code for traffic data. Example: "us", "gb", "de" \(default: "us"\) | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\) | +| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `paidPages` | array | List of pages receiving paid search traffic | +| ↳ `url` | string | The page URL | +| ↳ `traffic` | number | Estimated monthly paid search traffic | +| ↳ `keywords` | number | Number of paid keywords the page ranks for | +| ↳ `topKeyword` | string | The top keyword driving paid traffic to this page | +| ↳ `value` | number | Estimated monthly paid traffic cost in USD | +| ↳ `adsCount` | number | Number of unique ads shown for this page | + +### `ahrefs_anchors` + +Get the anchor text distribution for a target domain or URL's backlinks, showing how many links and referring domains use each anchor text. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\) | +| `history` | string | No | Historical scope: "live" \(currently live\), "all_time" \(default, includes lost backlinks\), or "since:YYYY-MM-DD" \(backlinks found since a date\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `anchors` | array | Anchor text distribution for the backlink profile | +| ↳ `anchor` | string | The anchor text | +| ↳ `backlinks` | number | Total backlinks using this anchor text | +| ↳ `dofollowBacklinks` | number | Number of dofollow backlinks using this anchor text | +| ↳ `referringDomains` | number | Number of unique referring domains using this anchor text | +| ↳ `firstSeen` | string | When a link with this anchor was first found | +| ↳ `lastSeen` | string | When a backlink with this anchor was last seen \(null if still live\) | + +### `ahrefs_related_terms` + +Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for ("also rank for") or also discuss ("also talk about"), with volume, difficulty, and CPC. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `keyword` | string | Yes | The seed keyword to find related terms for | +| `country` | string | No | Country code for keyword data. Example: "us", "gb", "de" \(default: "us"\) | +| `terms` | string | No | Type of related keywords to return: "also_rank_for", "also_talk_about", or "all" \(default: "all"\) | +| `viewFor` | string | No | Whether to derive related terms from the top 10 or top 100 ranking pages \(default: "top_10"\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `relatedTerms` | array | Related keyword ideas for the seed keyword | +| ↳ `keyword` | string | The related keyword | +| ↳ `volume` | number | Average monthly search volume | +| ↳ `keywordDifficulty` | number | Keyword difficulty score \(0-100\) | +| ↳ `cpc` | number | Cost per click in USD | +| ↳ `parentTopic` | string | The parent topic for this keyword | +| ↳ `trafficPotential` | number | Estimated traffic potential if ranking #1 | +| ↳ `intents` | object | Search intent flags \(informational, navigational, commercial, transactional, branded, local\) | +| ↳ `serpFeatures` | array | SERP features present in the results | + +### `ahrefs_domain_rating_history` + +Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" | +| `dateFrom` | string | Yes | Start date of the historical period, in YYYY-MM-DD format | +| `dateTo` | string | No | End date of the historical period, in YYYY-MM-DD format \(defaults to today\) | +| `historyGrouping` | string | No | Time interval for grouping data points: "daily", "weekly", or "monthly" \(default: "monthly"\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `domainRatings` | array | Historical Domain Rating data points | +| ↳ `date` | string | The date of the measurement | +| ↳ `domainRating` | number | Domain Rating score \(0-100\) on this date | + +### `ahrefs_metrics_history` + +Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" | +| `dateFrom` | string | Yes | Start date of the historical period, in YYYY-MM-DD format | +| `dateTo` | string | No | End date of the historical period, in YYYY-MM-DD format \(defaults to today\) | +| `volumeMode` | string | No | Search volume calculation: "monthly" or "average" \(default: "monthly"\) | +| `historyGrouping` | string | No | Time interval for grouping data points: "daily", "weekly", or "monthly" \(default: "monthly"\) | +| `country` | string | No | Country code for traffic data. Example: "us", "gb", "de" \(default: "us"\) | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `metricsHistory` | array | Historical organic and paid traffic data points | +| ↳ `date` | string | Date of the metric entry | +| ↳ `organicTraffic` | number | Estimated monthly organic visits | +| ↳ `organicCost` | number | Estimated monthly cost to replicate organic traffic via ads \(USD\) | +| ↳ `paidTraffic` | number | Estimated monthly paid search visits | +| ↳ `paidCost` | number | Estimated monthly paid search spend \(USD\) | + +### `ahrefs_refdomains_history` + +Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" | +| `dateFrom` | string | Yes | Start date of the historical period, in YYYY-MM-DD format | +| `dateTo` | string | No | End date of the historical period, in YYYY-MM-DD format \(defaults to today\) | +| `historyGrouping` | string | No | Time interval for grouping data points: "daily", "weekly", or "monthly" \(default: "monthly"\) | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `referringDomainsHistory` | array | Historical referring domains count data points | +| ↳ `date` | string | The date of the data point | +| ↳ `referringDomains` | number | Total number of unique domains linking to the target on this date | + +### `ahrefs_keywords_history` + +Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" | +| `dateFrom` | string | Yes | Start date of the historical period, in YYYY-MM-DD format | +| `dateTo` | string | No | End date of the historical period, in YYYY-MM-DD format \(defaults to today\) | +| `historyGrouping` | string | No | Time interval for grouping data points: "daily", "weekly", or "monthly" \(default: "monthly"\) | +| `country` | string | No | Country code for search results. Example: "us", "gb", "de" \(default: "us"\) | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `keywordsHistory` | array | Historical organic keyword ranking distribution | +| ↳ `date` | string | Date of the record | +| ↳ `top3` | number | Keywords ranking in top 3 organic results | +| ↳ `top4To10` | number | Keywords ranking in positions 4-10 | +| ↳ `top11To20` | number | Keywords ranking in positions 11-20 | +| ↳ `top21To50` | number | Keywords ranking in positions 21-50 | +| ↳ `top51Plus` | number | Keywords ranking in position 51 and beyond | + +### `ahrefs_batch_analysis` + +Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `targets` | string | Yes | Comma-separated list of domains or URLs to analyze. Example: "example.com,competitor.com" | +| `mode` | string | No | Analysis mode applied to every target: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\) | +| `protocol` | string | No | Protocol applied to every target: "both" \(default\), "http", or "https" | +| `country` | string | No | Country code for traffic data. Example: "us", "gb", "de" \(default: "us"\) | +| `volumeMode` | string | No | Search volume calculation: "monthly" or "average" \(default: "monthly"\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | array | Bulk metrics for each analyzed target, in submission order | +| ↳ `url` | string | The analyzed target URL or domain | +| ↳ `index` | number | Index of the target in the submitted list | +| ↳ `domainRating` | number | Domain Rating score \(0-100\) | +| ↳ `ahrefsRank` | number | Ahrefs Rank \(global ranking\) | +| ↳ `backlinks` | number | Total backlinks to the target | +| ↳ `referringDomains` | number | Unique domains linking to the target | +| ↳ `organicTraffic` | number | Estimated monthly organic traffic | +| ↳ `organicKeywords` | number | Number of organic keywords ranked \(top 100\) | +| ↳ `paidTraffic` | number | Estimated monthly paid search traffic | +| ↳ `error` | string | Error message if this target could not be analyzed | + +### `ahrefs_site_audit_page_explorer` + +Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | number | Yes | The Site Audit project ID \(found in the project URL in Ahrefs\) | +| `date` | string | No | Crawl date in YYYY-MM-DDThh:mm:ss format \(defaults to the most recent crawl\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `offset` | number | No | Number of results to skip, for pagination | +| `issueId` | string | No | Only return pages affected by this issue ID | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `auditPages` | array | List of crawled pages with health and SEO metrics | +| ↳ `url` | string | The crawled page URL | +| ↳ `httpCode` | number | HTTP status code returned by the URL | +| ↳ `title` | array | Page title tag\(s\) | +| ↳ `internalLinks` | number | Number of internal outgoing links | +| ↳ `externalLinks` | number | Number of external outgoing links | +| ↳ `backlinks` | number | Number of incoming external links to the page | +| ↳ `compliant` | boolean | Whether the page is indexable \(200 status, no canonical/noindex\) | +| ↳ `traffic` | number | Estimated monthly organic traffic to the page | + +### `ahrefs_rank_tracker_overview` + +Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | number | Yes | The Rank Tracker project ID \(found in the project URL in Ahrefs\) | +| `date` | string | Yes | Date to report rankings for, in YYYY-MM-DD format | +| `device` | string | Yes | Rankings device type: "desktop" or "mobile" | +| `dateCompared` | string | No | Comparison date in YYYY-MM-DD format, to compute position/traffic deltas | +| `volumeMode` | string | No | Search volume calculation: "monthly" or "average" \(default: "monthly"\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `overviews` | array | Ranking overview for each tracked keyword | +| ↳ `keyword` | string | The tracked keyword | +| ↳ `position` | number | Top organic search position | +| ↳ `volume` | number | Average monthly search volume | +| ↳ `keywordDifficulty` | number | Keyword difficulty score \(0-100\) | +| ↳ `url` | string | Top-ranking URL | +| ↳ `traffic` | number | Estimated monthly organic visits | +| ↳ `serpFeatures` | array | SERP features present in the results | +| ↳ `bestPositionKind` | string | Type of the top position \(organic, paid, or SERP feature\) | + +### `ahrefs_rank_tracker_serp_overview` + +Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | number | Yes | The Rank Tracker project ID \(found in the project URL in Ahrefs\) | +| `keyword` | string | Yes | The tracked keyword to retrieve SERP data for | +| `country` | string | Yes | Country code for the tracked keyword. Example: "us", "gb", "de" | +| `device` | string | Yes | Rankings device type: "desktop" or "mobile" | +| `topPositions` | number | No | Number of top organic positions to return \(defaults to all available\) | +| `date` | string | No | Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format | +| `locationId` | number | No | Location ID of the tracked keyword, if tracked at a specific location | +| `languageCode` | string | No | Language code of the tracked keyword | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `positions` | array | Every ranking result on the SERP for the tracked keyword | +| ↳ `position` | number | Position of the result in the SERP | +| ↳ `url` | string | URL of the ranking page | +| ↳ `title` | string | Page title | +| ↳ `type` | array | The kind of the position: organic, paid, or a SERP feature | +| ↳ `domainRating` | number | Domain Rating of the ranking domain | +| ↳ `urlRating` | number | URL Rating of the ranking page | +| ↳ `backlinks` | number | Total backlinks to the ranking domain | +| ↳ `refdomains` | number | Unique referring domains | +| ↳ `traffic` | number | Estimated monthly organic search traffic | +| ↳ `value` | number | Estimated monthly traffic value \(USD\) | +| ↳ `topKeyword` | string | Highest-traffic keyword ranking for this page | +| ↳ `topKeywordVolume` | number | Monthly search volume for the top keyword | +| ↳ `updateDate` | string | Date the SERP was last checked | + +### `ahrefs_rank_tracker_competitors_overview` + +Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword's volume and difficulty alongside every competitor's position, traffic, and traffic value. This endpoint is free and does not consume API units. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | number | Yes | The Rank Tracker project ID \(found in the project URL in Ahrefs\) | +| `date` | string | Yes | Date to report rankings for, in YYYY-MM-DD format | +| `device` | string | Yes | Rankings device type: "desktop" or "mobile" | +| `dateCompared` | string | No | Comparison date in YYYY-MM-DD format, to compute position/traffic deltas | +| `volumeMode` | string | No | Search volume calculation: "monthly" or "average" \(default: "monthly"\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `competitorKeywords` | array | Tracked keywords with competitor ranking data | +| ↳ `keyword` | string | The tracked keyword | +| ↳ `volume` | number | Average monthly search volume | +| ↳ `keywordDifficulty` | number | Keyword difficulty score \(0-100\) | +| ↳ `serpFeatures` | array | SERP features present in the results | +| ↳ `competitorsList` | array | Ranking data for each tracked competitor on this keyword | +| ↳ `url` | string | The competitor's ranking URL | +| ↳ `position` | number | Current ranking position | +| ↳ `bestPositionKind` | string | Type of the best position achieved | +| ↳ `traffic` | number | Estimated traffic to the competitor | +| ↳ `value` | number | Estimated traffic value \(USD\) | + +### `ahrefs_rank_tracker_competitors_stats` + +Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor's traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | number | Yes | The Rank Tracker project ID \(found in the project URL in Ahrefs\) | +| `date` | string | Yes | Date to report metrics for, in YYYY-MM-DD format | +| `device` | string | Yes | Rankings device type: "desktop" or "mobile" | +| `volumeMode` | string | No | Search volume calculation: "monthly" or "average" \(default: "monthly"\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `competitorsStats` | array | Aggregate stats for each tracked competitor | +| ↳ `competitor` | string | The competitor's URL | +| ↳ `traffic` | number | Estimated monthly organic visits | +| ↳ `trafficValue` | number | Estimated monthly organic traffic value \(USD\) | +| ↳ `averagePosition` | number | Average top organic position across tracked keywords | +| ↳ `pos1To3` | number | Keywords ranking in top 3 positions | +| ↳ `pos4To10` | number | Keywords ranking in positions 4-10 | +| ↳ `shareOfVoice` | number | Organic traffic share percentage | +| ↳ `shareOfTrafficValue` | number | Organic traffic value share percentage | + diff --git a/apps/docs/content/docs/en/integrations/cloudflare.mdx b/apps/docs/content/docs/en/integrations/cloudflare.mdx index 1876af3dda0..4c9bc7fffdd 100644 --- a/apps/docs/content/docs/en/integrations/cloudflare.mdx +++ b/apps/docs/content/docs/en/integrations/cloudflare.mdx @@ -47,7 +47,7 @@ Lists all zones (domains) in the Cloudflare account. | `page` | number | No | Page number for pagination \(default: 1\) | | `per_page` | number | No | Number of zones per page \(default: 20, max: 50\) | | `accountId` | string | No | Filter zones by account ID | -| `order` | string | No | Sort field \(name, status, account.id, account.name\) | +| `order` | string | No | Sort field \(name, status, account.id, account.name, plan.id\) | | `direction` | string | No | Sort direction \(asc, desc\) | | `match` | string | No | Match logic for filters \(any, all\). Default: all | | `apiKey` | string | Yes | Cloudflare API Token | @@ -158,7 +158,6 @@ Adds a new zone (domain) to the Cloudflare account. | `name` | string | Yes | The domain name to add \(e.g., "example.com"\) | | `accountId` | string | Yes | The Cloudflare account ID | | `type` | string | No | Zone type: "full" \(Cloudflare manages DNS\), "partial" \(CNAME setup\), or "secondary" \(secondary DNS\) | -| `jump_start` | boolean | No | Automatically attempt to fetch existing DNS records when creating the zone | | `apiKey` | string | Yes | Cloudflare API Token | #### Output @@ -238,8 +237,8 @@ Lists DNS records for a specific zone. | `order` | string | No | Sort field \(type, name, content, ttl, proxied\) | | `proxied` | boolean | No | Filter by proxy status | | `search` | string | No | Free-text search across record name, content, and value | -| `tag` | string | No | Filter by tags \(comma-separated\) | -| `tag_match` | string | No | Tag filter match logic: any or all | +| `tag` | string | No | Filter by an exact tag name | +| `tag_match` | string | No | Tag filter match logic: any or all. Only affects results when combined with multiple tag filter conditions; has no effect with the single exact-match Tag Filter above. | | `commentFilter` | string | No | Filter records by comment content \(substring match\) | | `apiKey` | string | Yes | Cloudflare API Token | @@ -441,7 +440,7 @@ Lists SSL/TLS certificate packs for a zone. ### `cloudflare_get_zone_settings` -Gets all settings for a zone including SSL mode, minification, caching level, and security settings. +Gets all settings for a zone including SSL mode, caching level, and security settings. #### Input @@ -455,7 +454,7 @@ Gets all settings for a zone including SSL mode, minification, caching level, an | Parameter | Type | Description | | --------- | ---- | ----------- | | `settings` | array | List of zone settings | -| ↳ `id` | string | Setting identifier \(e.g., ssl, minify, cache_level, security_level, always_use_https\) | +| ↳ `id` | string | Setting identifier \(e.g., ssl, cache_level, security_level, always_use_https\) | | ↳ `value` | string | Setting value as a string. Simple values returned as-is \(e.g., "full", "on"\). Complex values are JSON-stringified \(e.g., \ | | ↳ `editable` | boolean | Whether the setting can be modified for the current zone plan | | ↳ `modified_on` | string | ISO 8601 timestamp when the setting was last modified | @@ -463,22 +462,22 @@ Gets all settings for a zone including SSL mode, minification, caching level, an ### `cloudflare_update_zone_setting` -Updates a specific zone setting such as SSL mode, security level, cache level, minification, or other configuration. +Updates a specific zone setting such as SSL mode, security level, cache level, or other configuration. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `zoneId` | string | Yes | The zone ID to update settings for | -| `settingId` | string | Yes | Setting to update \(e.g., "ssl", "security_level", "cache_level", "minify", "always_use_https", "browser_cache_ttl", "http3", "min_tls_version", "ciphers"\) | -| `value` | string | Yes | New value for the setting as a string or JSON string for complex values \(e.g., "full" for SSL, "medium" for security_level, "aggressive" for cache_level, \'\{"css":"on","html":"on","js":"on"\}\' for minify, \'\["ECDHE-RSA-AES128-GCM-SHA256"\]\' for ciphers\) | +| `settingId` | string | Yes | Setting to update \(e.g., "ssl", "security_level", "cache_level", "always_use_https", "browser_cache_ttl", "http3", "min_tls_version", "ciphers"\) | +| `value` | string | Yes | New value for the setting as a string or JSON string for complex values \(e.g., "full" for SSL, "medium" for security_level, "aggressive" for cache_level, \'\["ECDHE-RSA-AES128-GCM-SHA256"\]\' for ciphers\) | | `apiKey` | string | Yes | Cloudflare API Token | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Setting identifier \(e.g., ssl, minify, cache_level\) | +| `id` | string | Setting identifier \(e.g., ssl, cache_level, security_level\) | | `value` | string | Updated setting value as a string. Simple values returned as-is \(e.g., "full", "on"\). Complex values are JSON-stringified. | | `editable` | boolean | Whether the setting can be modified for the current zone plan | | `modified_on` | string | ISO 8601 timestamp when the setting was last modified | diff --git a/apps/docs/content/docs/en/integrations/index.mdx b/apps/docs/content/docs/en/integrations/index.mdx index 79c68b05011..bc65ecb4a0f 100644 --- a/apps/docs/content/docs/en/integrations/index.mdx +++ b/apps/docs/content/docs/en/integrations/index.mdx @@ -110,12 +110,6 @@ Open a connection from the **Connected** list to manage it: If you disconnect an integration that is used in a workflow, that workflow will fail at any block referencing it. Update blocks before disconnecting.
-## Email polling groups - -The Gmail and Outlook email triggers can watch several team members' inboxes through a single trigger, called an **email polling group** (Team and Enterprise plans). An admin creates a group under **Settings → Email Polling**, picks Gmail or Outlook, and invites members by email; each invitee connects their own inbox through a link. On an email trigger, select the group from the credentials dropdown instead of a single account, and the trigger routes everyone's mail through the workflow. - -Inviting someone to a group grants inbox access for that trigger only, which is separate from workspace membership. - diff --git a/apps/docs/content/docs/es/triggers/index.mdx b/apps/docs/content/docs/es/triggers/index.mdx index 94fcb23e76c..730cdbcc803 100644 --- a/apps/docs/content/docs/es/triggers/index.mdx +++ b/apps/docs/content/docs/es/triggers/index.mdx @@ -33,9 +33,6 @@ Utiliza el bloque Start para todo lo que se origina desde el editor, despliegue Monitorea feeds RSS y Atom para detectar contenido nuevo - - Monitorea bandejas de entrada de Gmail y Outlook del equipo - ## Comparación rápida @@ -46,7 +43,6 @@ Utiliza el bloque Start para todo lo que se origina desde el editor, despliegue | **Schedule** | Temporizador gestionado en el bloque de programación | | **Webhook** | Al recibir una solicitud HTTP entrante | | **RSS Feed** | Nuevo elemento publicado en el feed | -| **Email Polling Groups** | Nuevo correo electrónico recibido en bandejas de entrada de Gmail o Outlook del equipo | > El bloque Start siempre expone los campos `input`, `conversationId` y `files`. Añade campos personalizados al formato de entrada para datos estructurados adicionales. diff --git a/apps/docs/content/docs/fr/enterprise/index.mdx b/apps/docs/content/docs/fr/enterprise/index.mdx index 4af1489fda0..f7546d66af4 100644 --- a/apps/docs/content/docs/fr/enterprise/index.mdx +++ b/apps/docs/content/docs/fr/enterprise/index.mdx @@ -79,7 +79,6 @@ Pour les déploiements auto-hébergés, les fonctionnalités entreprise peuvent | Variable | Description | |----------|-------------| | `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Authentification unique avec SAML/OIDC | -| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Groupes de sondage pour les déclencheurs d'e-mail | | `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Désactiver globalement les invitations aux espaces de travail/organisations | diff --git a/apps/docs/content/docs/fr/triggers/index.mdx b/apps/docs/content/docs/fr/triggers/index.mdx index 2102b482f7e..ef9b408df06 100644 --- a/apps/docs/content/docs/fr/triggers/index.mdx +++ b/apps/docs/content/docs/fr/triggers/index.mdx @@ -33,9 +33,6 @@ Utilisez le bloc Démarrer pour tout ce qui provient de l'éditeur, du déploiem Surveiller les flux RSS et Atom pour détecter du nouveau contenu - - Surveiller les boîtes de réception Gmail et Outlook de l'équipe - ## Comparaison rapide @@ -46,7 +43,6 @@ Utilisez le bloc Démarrer pour tout ce qui provient de l'éditeur, du déploiem | **Schedule** | Minuteur géré dans le bloc de planification | | **Webhook** | Lors d'une requête HTTP entrante | | **RSS Feed** | Nouvel élément publié dans le flux | -| **Email Polling Groups** | Nouvel e-mail reçu dans les boîtes de réception Gmail ou Outlook de l'équipe | > Le bloc Démarrer expose toujours les champs `input`, `conversationId` et `files`. Ajoutez des champs personnalisés au format d'entrée pour des données structurées supplémentaires. diff --git a/apps/docs/content/docs/ja/enterprise/index.mdx b/apps/docs/content/docs/ja/enterprise/index.mdx index b769395011d..473697ad064 100644 --- a/apps/docs/content/docs/ja/enterprise/index.mdx +++ b/apps/docs/content/docs/ja/enterprise/index.mdx @@ -78,7 +78,6 @@ Simのホストキーの代わりに、AIモデルプロバイダー用の独自 | 変数 | 説明 | |----------|-------------| | `SSO_ENABLED`、`NEXT_PUBLIC_SSO_ENABLED` | SAML/OIDCによるシングルサインオン | -| `CREDENTIAL_SETS_ENABLED`、`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | メールトリガー用のポーリンググループ | | `DISABLE_INVITATIONS`、`NEXT_PUBLIC_DISABLE_INVITATIONS` | ワークスペース/組織への招待をグローバルに無効化 | diff --git a/apps/docs/content/docs/ja/triggers/index.mdx b/apps/docs/content/docs/ja/triggers/index.mdx index 9095371cf32..7bf66bd07c9 100644 --- a/apps/docs/content/docs/ja/triggers/index.mdx +++ b/apps/docs/content/docs/ja/triggers/index.mdx @@ -33,9 +33,6 @@ import { Image } from '@/components/ui/image' RSSおよびAtomフィードの新しいコンテンツを監視 - - チームのGmailおよびOutlook受信トレイを監視 - ## クイック比較 @@ -46,7 +43,6 @@ import { Image } from '@/components/ui/image' | **Schedule** | スケジュールブロックで管理されるタイマー | | **Webhook** | インバウンドHTTPリクエスト時 | | **RSS Feed** | フィードに新しいアイテムが公開された時 | -| **Email Polling Groups** | チームのGmailまたはOutlook受信トレイに新しいメールが受信された時 | > スタートブロックは常に `input`、`conversationId`、および `files` フィールドを公開します。追加の構造化データには入力フォーマットにカスタムフィールドを追加してください。 @@ -69,25 +65,3 @@ import { Image } from '@/components/ui/image' ワークフローに複数のトリガーがある場合、最も優先度の高いトリガーが実行されます。例えば、スタートブロックとウェブフックトリガーの両方がある場合、実行をクリックするとスタートブロックが実行されます。 **モックペイロードを持つ外部トリガー**: 外部トリガー(ウェブフックと連携)が手動で実行される場合、Simはトリガーの予想されるデータ構造に基づいてモックペイロードを自動生成します。これにより、テスト中に下流のブロックが変数を正しく解決できるようになります。 - -## Email Polling Groups - -Polling Groupsを使用すると、単一のトリガーで複数のチームメンバーのGmailまたはOutlook受信トレイを監視できます。TeamまたはEnterpriseプランが必要です。 - -**Polling Groupの作成**(管理者/オーナー) - -1. **設定 → Email Polling**に移動 -2. **作成**をクリックし、GmailまたはOutlookを選択 -3. グループの名前を入力 - -**メンバーの招待** - -1. Polling Groupの**メンバーを追加**をクリック -2. メールアドレスを入力(カンマまたは改行で区切る、またはCSVをドラッグ&ドロップ) -3. **招待を送信**をクリック - -招待された人は、アカウントを接続するためのリンクが記載されたメールを受信します。接続されると、その受信トレイは自動的にPolling Groupに含まれます。招待された人は、Sim組織のメンバーである必要はありません。 - -**ワークフローでの使用** - -メールトリガーを設定する際、個別のアカウントではなく、認証情報ドロップダウンからPolling Groupを選択します。システムは各メンバーのWebhookを作成し、すべてのメールをワークフローを通じてルーティングします。 diff --git a/apps/docs/content/docs/zh/enterprise/index.mdx b/apps/docs/content/docs/zh/enterprise/index.mdx index 25c7d8da7ed..2eb7f2bc382 100644 --- a/apps/docs/content/docs/zh/enterprise/index.mdx +++ b/apps/docs/content/docs/zh/enterprise/index.mdx @@ -78,7 +78,6 @@ Sim 企业版为需要更高安全性、合规性和管理能力的组织提供 | 变量 | 描述 | |----------|-------------| | `SSO_ENABLED`,`NEXT_PUBLIC_SSO_ENABLED` | 使用 SAML/OIDC 的单点登录 | -| `CREDENTIAL_SETS_ENABLED`,`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | 用于邮件触发器的轮询组 | | `DISABLE_INVITATIONS`,`NEXT_PUBLIC_DISABLE_INVITATIONS` | 全局禁用工作区/组织邀请 | diff --git a/apps/docs/content/docs/zh/triggers/index.mdx b/apps/docs/content/docs/zh/triggers/index.mdx index a17b11dd325..bdbf48cc1b4 100644 --- a/apps/docs/content/docs/zh/triggers/index.mdx +++ b/apps/docs/content/docs/zh/triggers/index.mdx @@ -33,9 +33,6 @@ import { Image } from '@/components/ui/image' 监控 RSS 和 Atom 订阅源的新内容 - - 监控团队 Gmail 和 Outlook 收件箱 - ## 快速对比 @@ -46,7 +43,6 @@ import { Image } from '@/components/ui/image' | **Schedule** | 在 schedule 块中管理的定时器 | | **Webhook** | 收到入站 HTTP 请求时 | | **RSS Feed** | 订阅源中有新内容发布时 | -| **Email Polling Groups** | 团队 Gmail 或 Outlook 收件箱收到新邮件时 | > Start 块始终公开 `input`、`conversationId` 和 `files` 字段。通过向输入格式添加自定义字段来增加结构化数据。 diff --git a/apps/docs/public/static/files/editor/overview.png b/apps/docs/public/static/files/editor/overview.png new file mode 100644 index 00000000000..0f736b2eeff Binary files /dev/null and b/apps/docs/public/static/files/editor/overview.png differ diff --git a/apps/pii/server.py b/apps/pii/server.py index 4029bac1019..e2f7ad706d3 100644 --- a/apps/pii/server.py +++ b/apps/pii/server.py @@ -181,6 +181,24 @@ class AnonymizeBatchRequest(BaseModel): operators: dict[str, dict[str, Any]] | None = None +class RedactRequest(BaseModel): + text: str + language: str = "en" + entities: list[str] | None = None + score_threshold: float | None = None + anonymizers: dict[str, dict[str, Any]] | None = None + operators: dict[str, dict[str, Any]] | None = None + + +class RedactBatchRequest(BaseModel): + texts: list[str] + language: str = "en" + entities: list[str] | None = None + score_threshold: float | None = None + anonymizers: dict[str, dict[str, Any]] | None = None + operators: dict[str, dict[str, Any]] | None = None + + def build_operators( raw_operators: dict[str, dict[str, Any]] | None, ) -> dict[str, OperatorConfig] | None: @@ -296,3 +314,74 @@ def anonymize_batch(req: AnonymizeBatchRequest) -> dict[str, list[str]]: for item in req.items ] } + + +@app.post("/redact") +def redact(req: RedactRequest) -> dict[str, str]: + """Analyze + anonymize one text in a single round-trip (the combined + counterpart to /analyze followed by /anonymize). Returns masked text; a text + with no detected PII passes through unchanged. The analyzer results feed the + anonymizer directly (no dict round-trip).""" + started = time.perf_counter() + operators = build_operators(req.anonymizers or req.operators) + results = analyzer.analyze( + text=req.text, + language=req.language, + entities=req.entities or None, + score_threshold=req.score_threshold, + ) + text = ( + req.text + if not results + else anonymizer.anonymize( + text=req.text, analyzer_results=results, operators=operators + ).text + ) + logger.info( + "redact lang=%s chars=%d spans=%d duration_ms=%.1f", + req.language, + len(req.text), + len(results), + (time.perf_counter() - started) * 1000, + ) + return {"text": text} + + +@app.post("/redact_batch") +def redact_batch(req: RedactBatchRequest) -> dict[str, list[str]]: + """Analyze + anonymize many texts in a single round-trip (the combined + counterpart to /analyze_batch followed by /anonymize_batch). Returns masked + text per input in request order; texts with no detected PII pass through + unchanged. Analysis batches through spaCy nlp.pipe; the analyzer results feed + the anonymizer directly (no dict round-trip), and anonymization runs only on + texts that actually matched.""" + started = time.perf_counter() + operators = build_operators(req.anonymizers or req.operators) + analyzed = list( + batch_analyzer.analyze_iterator( + texts=req.texts, + language=req.language, + entities=req.entities or None, + score_threshold=req.score_threshold, + ) + ) + masked: list[str] = [] + total_spans = 0 + for text, per_text in zip(req.texts, analyzed): + if not per_text: + masked.append(text) + continue + total_spans += len(per_text) + masked.append( + anonymizer.anonymize( + text=text, analyzer_results=per_text, operators=operators + ).text + ) + logger.info( + "redact_batch lang=%s texts=%d spans=%d duration_ms=%.1f", + req.language, + len(req.texts), + total_spans, + (time.perf_counter() - started) * 1000, + ) + return {"texts": masked} diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 5553cfa6fe9..e7aa86ea1f9 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -121,10 +121,7 @@ function SignupFormContent({ } const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : '' const isInviteFlow = useMemo( - () => - searchParams.get('invite_flow') === 'true' || - redirectUrl.startsWith('/invite/') || - redirectUrl.startsWith('/credential-account/'), + () => searchParams.get('invite_flow') === 'true' || redirectUrl.startsWith('/invite/'), [searchParams, redirectUrl] ) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index 3681ac5cc36..97215a0d923 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -13,16 +13,8 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockSyncAllWebhooksForCredentialSet } = vi.hoisted(() => ({ - mockSyncAllWebhooksForCredentialSet: vi.fn().mockResolvedValue({}), -})) - vi.mock('@sim/db', () => dbChainMock) -vi.mock('@/lib/webhooks/utils.server', () => ({ - syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet, -})) - vi.mock('@sim/audit', () => auditMock) import { POST } from '@/app/api/auth/oauth/disconnect/route' diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index 101cbed9e44..44840b2d568 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { account, credential, credentialSet, credentialSetMember } from '@sim/db/schema' +import { account, credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, like, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -11,7 +11,6 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteCredential } from '@/lib/credentials/deletion' import { captureServerEvent } from '@/lib/posthog/server' -import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' export const dynamic = 'force-dynamic' @@ -104,49 +103,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await db.delete(account).where(inArray(account.id, targetAccountIds)) } - // Sync webhooks for all credential sets the user is a member of - // This removes webhooks that were using the disconnected credential - const userMemberships = await db - .select({ - id: credentialSetMember.id, - credentialSetId: credentialSetMember.credentialSetId, - providerId: credentialSet.providerId, - }) - .from(credentialSetMember) - .innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id)) - .where( - and( - eq(credentialSetMember.userId, session.user.id), - eq(credentialSetMember.status, 'active') - ) - ) - - for (const membership of userMemberships) { - // Only sync if the credential set matches this provider - // Credential sets store OAuth provider IDs like 'google-email' or 'outlook' - const matchesProvider = - membership.providerId === provider || - membership.providerId === providerId || - membership.providerId?.startsWith(`${provider}-`) - - if (matchesProvider) { - try { - await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId) - logger.info(`[${requestId}] Synced webhooks after credential disconnect`, { - credentialSetId: membership.credentialSetId, - provider, - }) - } catch (error) { - // Log but don't fail the disconnect - credential is already removed - logger.error(`[${requestId}] Failed to sync webhooks after credential disconnect`, { - credentialSetId: membership.credentialSetId, - provider, - error, - }) - } - } - } - recordAudit({ workspaceId: null, actorId: session.user.id, diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 732f157d581..041a6a2ce53 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -1,9 +1,9 @@ import { createSign } from 'crypto' import { db } from '@sim/db' -import { account, credential, credentialSetMember } from '@sim/db/schema' +import { account, credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, desc, eq, inArray } from 'drizzle-orm' +import { and, desc, eq } from 'drizzle-orm' import { withLeaderLock } from '@/lib/concurrency/leader-lock' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { decryptSecret } from '@/lib/core/security/encryption' @@ -639,89 +639,3 @@ export async function refreshTokenIfNeeded( } throw new Error('Failed to refresh token') } - -export interface CredentialSetCredential { - userId: string - credentialId: string - accessToken: string - providerId: string -} - -export async function getCredentialsForCredentialSet( - credentialSetId: string, - providerId: string -): Promise { - logger.info(`Getting credentials for credential set ${credentialSetId}, provider ${providerId}`) - - const members = await db - .select({ userId: credentialSetMember.userId }) - .from(credentialSetMember) - .where( - and( - eq(credentialSetMember.credentialSetId, credentialSetId), - eq(credentialSetMember.status, 'active') - ) - ) - - logger.info(`Found ${members.length} active members in credential set ${credentialSetId}`) - - if (members.length === 0) { - logger.warn(`No active members found for credential set ${credentialSetId}`) - return [] - } - - const userIds = members.map((m) => m.userId) - logger.debug(`Member user IDs: ${userIds.join(', ')}`) - - const credentials = await db - .select({ - id: account.id, - userId: account.userId, - providerId: account.providerId, - accessToken: account.accessToken, - refreshToken: account.refreshToken, - accessTokenExpiresAt: account.accessTokenExpiresAt, - }) - .from(account) - .where(and(inArray(account.userId, userIds), eq(account.providerId, providerId))) - - logger.info( - `Found ${credentials.length} credentials with provider ${providerId} for ${members.length} members` - ) - - const results: CredentialSetCredential[] = [] - - for (const cred of credentials) { - const now = new Date() - const tokenExpiry = cred.accessTokenExpiresAt - const shouldRefresh = - !!cred.refreshToken && (!cred.accessToken || (tokenExpiry && tokenExpiry < now)) - - let accessToken = cred.accessToken - - if (shouldRefresh && cred.refreshToken) { - const fresh = await performCoalescedRefresh({ - accountId: cred.id, - providerId, - refreshToken: cred.refreshToken, - userId: cred.userId, - }) - if (fresh) accessToken = fresh - } - - if (accessToken) { - results.push({ - userId: cred.userId, - credentialId: cred.id, - accessToken, - providerId, - }) - } - } - - logger.info( - `Found ${results.length} valid credentials for credential set ${credentialSetId}, provider ${providerId}` - ) - - return results -} diff --git a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts deleted file mode 100644 index cdd1fc4b9f5..00000000000 --- a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credentialSet, credentialSetInvitation, member, organization, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails' -import { credentialSetInvitationParamsSchema } from '@/lib/api/contracts/credential-sets' -import { getSession } from '@/lib/auth' -import { hasCredentialSetsAccess } from '@/lib/billing' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { sendEmail } from '@/lib/messaging/email/mailer' - -const logger = createLogger('CredentialSetInviteResend') - -async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { - const [set] = await db - .select({ - id: credentialSet.id, - organizationId: credentialSet.organizationId, - name: credentialSet.name, - providerId: credentialSet.providerId, - }) - .from(credentialSet) - .where(eq(credentialSet.id, credentialSetId)) - .limit(1) - - if (!set) return null - - const [membership] = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) - .limit(1) - - if (!membership) return null - - return { set, role: membership.role } -} - -export const POST = withRouteHandler( - async ( - req: NextRequest, - { params }: { params: Promise<{ id: string; invitationId: string }> } - ) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id, invitationId } = credentialSetInvitationParamsSchema.parse(await params) - - try { - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - const [invitation] = await db - .select() - .from(credentialSetInvitation) - .where( - and( - eq(credentialSetInvitation.id, invitationId), - eq(credentialSetInvitation.credentialSetId, id) - ) - ) - .limit(1) - - if (!invitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - if (invitation.status !== 'pending') { - return NextResponse.json( - { error: 'Only pending invitations can be resent' }, - { status: 400 } - ) - } - - // Update expiration - const newExpiresAt = new Date() - newExpiresAt.setDate(newExpiresAt.getDate() + 7) - - await db - .update(credentialSetInvitation) - .set({ expiresAt: newExpiresAt }) - .where(eq(credentialSetInvitation.id, invitationId)) - - const inviteUrl = `${getBaseUrl()}/credential-account/${invitation.token}` - - // Send email if email address exists - if (invitation.email) { - try { - const [inviter] = await db - .select({ name: user.name }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - const [org] = await db - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, result.set.organizationId)) - .limit(1) - - const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email' - const emailHtml = await renderPollingGroupInvitationEmail({ - inviterName: inviter?.name || 'A team member', - organizationName: org?.name || 'your organization', - pollingGroupName: result.set.name, - provider, - inviteLink: inviteUrl, - }) - - const emailResult = await sendEmail({ - to: invitation.email, - subject: getEmailSubject('polling-group-invitation'), - html: emailHtml, - emailType: 'transactional', - }) - - if (!emailResult.success) { - logger.warn('Failed to resend invitation email', { - email: invitation.email, - error: emailResult.message, - }) - return NextResponse.json({ error: 'Failed to send email' }, { status: 500 }) - } - } catch (emailError) { - logger.error('Error sending invitation email', emailError) - return NextResponse.json({ error: 'Failed to send email' }, { status: 500 }) - } - } - - logger.info('Resent credential set invitation', { - credentialSetId: id, - invitationId, - userId: session.user.id, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_SET_INVITATION_RESENT, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - resourceName: result.set.name, - description: `Resent credential set invitation to ${invitation.email}`, - metadata: { - invitationId, - targetEmail: invitation.email, - providerId: result.set.providerId, - credentialSetName: result.set.name, - }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error resending invitation', error) - return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts deleted file mode 100644 index 0594c9f5c5e..00000000000 --- a/apps/sim/app/api/credential-sets/[id]/invite/route.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credentialSet, credentialSetInvitation, member, organization, user } 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 { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails' -import { - cancelCredentialSetInvitationQuerySchema, - createCredentialSetInvitationContract, -} from '@/lib/api/contracts/credential-sets' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { hasCredentialSetsAccess } from '@/lib/billing' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { sendEmail } from '@/lib/messaging/email/mailer' - -const logger = createLogger('CredentialSetInvite') - -async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { - const [set] = await db - .select({ - id: credentialSet.id, - organizationId: credentialSet.organizationId, - name: credentialSet.name, - providerId: credentialSet.providerId, - }) - .from(credentialSet) - .where(eq(credentialSet.id, credentialSetId)) - .limit(1) - - if (!set) return null - - const [membership] = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) - .limit(1) - - if (!membership) return null - - return { set, role: membership.role } -} - -export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await params - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - const invitations = await db - .select({ - id: credentialSetInvitation.id, - credentialSetId: credentialSetInvitation.credentialSetId, - email: credentialSetInvitation.email, - status: credentialSetInvitation.status, - expiresAt: credentialSetInvitation.expiresAt, - createdAt: credentialSetInvitation.createdAt, - invitedBy: credentialSetInvitation.invitedBy, - }) - .from(credentialSetInvitation) - .where(eq(credentialSetInvitation.credentialSetId, id)) - - return NextResponse.json({ invitations }) - } -) - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - try { - const parsed = await parseRequest(createCredentialSetInvitationContract, req, context, { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { email } = parsed.data.body - - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - const token = generateId() - const expiresAt = new Date() - expiresAt.setDate(expiresAt.getDate() + 7) - - const invitation = { - id: generateId(), - credentialSetId: id, - email: email || null, - token, - invitedBy: session.user.id, - status: 'pending' as const, - expiresAt, - createdAt: new Date(), - } - - await db.insert(credentialSetInvitation).values(invitation) - - const inviteUrl = `${getBaseUrl()}/credential-account/${token}` - - // Send email if email address was provided - if (email) { - try { - // Get inviter name - const [inviter] = await db - .select({ name: user.name }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - // Get organization name - const [org] = await db - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, result.set.organizationId)) - .limit(1) - - const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email' - const emailHtml = await renderPollingGroupInvitationEmail({ - inviterName: inviter?.name || 'A team member', - organizationName: org?.name || 'your organization', - pollingGroupName: result.set.name, - provider, - inviteLink: inviteUrl, - }) - - const emailResult = await sendEmail({ - to: email, - subject: getEmailSubject('polling-group-invitation'), - html: emailHtml, - emailType: 'transactional', - }) - - if (!emailResult.success) { - logger.warn('Failed to send invitation email', { - email, - error: emailResult.message, - }) - } - } catch (emailError) { - logger.error('Error sending invitation email', emailError) - // Don't fail the invitation creation if email fails - } - } - - logger.info('Created credential set invitation', { - credentialSetId: id, - invitationId: invitation.id, - userId: session.user.id, - emailSent: !!email, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_INVITATION_CREATED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.set.name, - description: `Created invitation for credential set "${result.set.name}"${email ? ` to ${email}` : ''}`, - metadata: { - invitationId: invitation.id, - targetEmail: email || undefined, - providerId: result.set.providerId, - credentialSetName: result.set.name, - }, - request: req, - }) - - return NextResponse.json({ - invitation: { - ...invitation, - inviteUrl, - }, - }) - } catch (error) { - logger.error('Error creating invitation', error) - return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await params - const { searchParams } = new URL(req.url) - const validation = cancelCredentialSetInvitationQuerySchema.safeParse({ - invitationId: searchParams.get('invitationId') ?? '', - }) - - if (!validation.success) { - return NextResponse.json( - { error: getValidationErrorMessage(validation.error) }, - { status: 400 } - ) - } - - const { invitationId } = validation.data - - try { - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - const [revokedInvitation] = await db - .update(credentialSetInvitation) - .set({ status: 'cancelled' }) - .where( - and( - eq(credentialSetInvitation.id, invitationId), - eq(credentialSetInvitation.credentialSetId, id) - ) - ) - .returning({ email: credentialSetInvitation.email }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_INVITATION_REVOKED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.set.name, - description: `Revoked invitation "${invitationId}" for credential set "${result.set.name}"`, - metadata: { targetEmail: revokedInvitation?.email ?? undefined }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error cancelling invitation', error) - return NextResponse.json({ error: 'Failed to cancel invitation' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts deleted file mode 100644 index b49aadcfae0..00000000000 --- a/apps/sim/app/api/credential-sets/[id]/members/route.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { account, credentialSet, credentialSetMember, member, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, inArray } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { removeCredentialSetMemberQuerySchema } from '@/lib/api/contracts/credential-sets' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { hasCredentialSetsAccess } from '@/lib/billing' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' - -const logger = createLogger('CredentialSetMembers') - -async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { - const [set] = await db - .select({ - id: credentialSet.id, - name: credentialSet.name, - organizationId: credentialSet.organizationId, - providerId: credentialSet.providerId, - }) - .from(credentialSet) - .where(eq(credentialSet.id, credentialSetId)) - .limit(1) - - if (!set) return null - - const [membership] = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) - .limit(1) - - if (!membership) return null - - return { set, role: membership.role } -} - -export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await params - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - const members = await db - .select({ - id: credentialSetMember.id, - userId: credentialSetMember.userId, - status: credentialSetMember.status, - joinedAt: credentialSetMember.joinedAt, - createdAt: credentialSetMember.createdAt, - userName: user.name, - userEmail: user.email, - userImage: user.image, - }) - .from(credentialSetMember) - .leftJoin(user, eq(credentialSetMember.userId, user.id)) - .where(eq(credentialSetMember.credentialSetId, id)) - - // Get credentials for all active members filtered by the polling group's provider - const activeMembers = members.filter((m) => m.status === 'active') - const memberUserIds = activeMembers.map((m) => m.userId) - - let credentials: { userId: string; providerId: string; accountId: string }[] = [] - if (memberUserIds.length > 0 && result.set.providerId) { - credentials = await db - .select({ - userId: account.userId, - providerId: account.providerId, - accountId: account.accountId, - }) - .from(account) - .where( - and(inArray(account.userId, memberUserIds), eq(account.providerId, result.set.providerId)) - ) - } - - // Group credentials by userId - const credentialsByUser = credentials.reduce( - (acc, cred) => { - if (!acc[cred.userId]) { - acc[cred.userId] = [] - } - acc[cred.userId].push({ - providerId: cred.providerId, - accountId: cred.accountId, - }) - return acc - }, - {} as Record - ) - - // Attach credentials to members - const membersWithCredentials = members.map((m) => ({ - ...m, - credentials: credentialsByUser[m.userId] || [], - })) - - return NextResponse.json({ members: membersWithCredentials }) - } -) - -export const DELETE = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await params - const { searchParams } = new URL(req.url) - const validation = removeCredentialSetMemberQuerySchema.safeParse({ - memberId: searchParams.get('memberId') ?? '', - }) - - if (!validation.success) { - return NextResponse.json( - { error: getValidationErrorMessage(validation.error) }, - { status: 400 } - ) - } - - const { memberId } = validation.data - - try { - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - const [memberToRemove] = await db - .select({ - id: credentialSetMember.id, - credentialSetId: credentialSetMember.credentialSetId, - userId: credentialSetMember.userId, - status: credentialSetMember.status, - email: user.email, - }) - .from(credentialSetMember) - .innerJoin(user, eq(credentialSetMember.userId, user.id)) - .where( - and(eq(credentialSetMember.id, memberId), eq(credentialSetMember.credentialSetId, id)) - ) - .limit(1) - - if (!memberToRemove) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } - - const requestId = generateId().slice(0, 8) - - await db.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId)) - - // Runs after the deletion commits: the sync performs external HTTP - // (OAuth refresh, provider unsubscribe) and must not hold a pooled - // connection. A sync failure must not fail the committed mutation — - // it self-heals on the next membership change/deploy. - try { - const syncResult = await syncAllWebhooksForCredentialSet(id, requestId) - logger.info('Synced webhooks after member removed', { - credentialSetId: id, - ...syncResult, - }) - } catch (syncError) { - logger.error('Webhook sync failed after member removal', { - credentialSetId: id, - error: syncError, - }) - } - - logger.info('Removed member from credential set', { - credentialSetId: id, - memberId, - userId: session.user.id, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_MEMBER_REMOVED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.set.name, - description: `Removed member from credential set "${result.set.name}"`, - metadata: { - memberId, - memberUserId: memberToRemove.userId, - targetEmail: memberToRemove.email ?? undefined, - providerId: result.set.providerId, - }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error removing member from credential set', error) - return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts deleted file mode 100644 index 8e7241382ee..00000000000 --- a/apps/sim/app/api/credential-sets/[id]/route.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credentialSet, credentialSetMember, member, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, count, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { updateCredentialSetContract } from '@/lib/api/contracts/credential-sets' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { hasCredentialSetsAccess } from '@/lib/billing' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CredentialSet') - -async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { - const [set] = await db - .select({ - id: credentialSet.id, - organizationId: credentialSet.organizationId, - name: credentialSet.name, - description: credentialSet.description, - providerId: credentialSet.providerId, - createdBy: credentialSet.createdBy, - createdAt: credentialSet.createdAt, - updatedAt: credentialSet.updatedAt, - creatorName: user.name, - creatorEmail: user.email, - }) - .from(credentialSet) - .leftJoin(user, eq(credentialSet.createdBy, user.id)) - .where(eq(credentialSet.id, credentialSetId)) - .limit(1) - - if (!set) return null - - const [membership] = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) - .limit(1) - - if (!membership) return null - - const [memberCount] = await db - .select({ count: count() }) - .from(credentialSetMember) - .where( - and( - eq(credentialSetMember.credentialSetId, credentialSetId), - eq(credentialSetMember.status, 'active') - ) - ) - - return { - set: { - ...set, - memberCount: memberCount?.count ?? 0, - }, - role: membership.role, - } -} - -export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await params - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - return NextResponse.json({ credentialSet: result.set }) - } -) - -export const PUT = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await context.params - - try { - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - const parsed = await parseRequest(updateCredentialSetContract, req, context) - if (!parsed.success) return parsed.response - const updates = parsed.data.body - - if (updates.name) { - const existingSet = await db - .select({ id: credentialSet.id }) - .from(credentialSet) - .where( - and( - eq(credentialSet.organizationId, result.set.organizationId), - eq(credentialSet.name, updates.name) - ) - ) - .limit(1) - - if (existingSet.length > 0 && existingSet[0].id !== id) { - return NextResponse.json( - { error: 'A credential set with this name already exists' }, - { status: 409 } - ) - } - } - - await db - .update(credentialSet) - .set({ - ...updates, - updatedAt: new Date(), - }) - .where(eq(credentialSet.id, id)) - - const [updated] = await db - .select() - .from(credentialSet) - .where(eq(credentialSet.id, id)) - .limit(1) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_UPDATED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: updated?.name ?? result.set.name, - description: `Updated credential set "${updated?.name ?? result.set.name}"`, - metadata: { - organizationId: result.set.organizationId, - providerId: result.set.providerId, - updatedFields: Object.keys(updates).filter( - (k) => updates[k as keyof typeof updates] !== undefined - ), - }, - request: req, - }) - - return NextResponse.json({ credentialSet: updated }) - } catch (error) { - logger.error('Error updating credential set', error) - return NextResponse.json({ error: 'Failed to update credential set' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await params - - try { - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - await db.delete(credentialSetMember).where(eq(credentialSetMember.credentialSetId, id)) - await db.delete(credentialSet).where(eq(credentialSet.id, id)) - - logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_DELETED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.set.name, - description: `Deleted credential set "${result.set.name}"`, - metadata: { organizationId: result.set.organizationId, providerId: result.set.providerId }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error deleting credential set', error) - return NextResponse.json({ error: 'Failed to delete credential set' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/credential-sets/invitations/route.ts b/apps/sim/app/api/credential-sets/invitations/route.ts deleted file mode 100644 index a6da9ca82b2..00000000000 --- a/apps/sim/app/api/credential-sets/invitations/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { db } from '@sim/db' -import { credentialSet, credentialSetInvitation, organization, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, gt } from 'drizzle-orm' -import { NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CredentialSetInvitations') - -export const GET = withRouteHandler(async () => { - const session = await getSession() - - if (!session?.user?.id || !session?.user?.email) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - // Scope to invitations addressed to the caller's own email only. Open-link - // (null-email) invites carry a bearer token redeemed via the out-of-band URL - // and must never be listed, or any user could accept another org's invite. - const invitations = await db - .select({ - invitationId: credentialSetInvitation.id, - token: credentialSetInvitation.token, - status: credentialSetInvitation.status, - expiresAt: credentialSetInvitation.expiresAt, - createdAt: credentialSetInvitation.createdAt, - credentialSetId: credentialSet.id, - credentialSetName: credentialSet.name, - providerId: credentialSet.providerId, - organizationId: organization.id, - organizationName: organization.name, - invitedByName: user.name, - invitedByEmail: user.email, - }) - .from(credentialSetInvitation) - .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) - .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) - .leftJoin(user, eq(credentialSetInvitation.invitedBy, user.id)) - .where( - and( - eq(credentialSetInvitation.email, session.user.email), - eq(credentialSetInvitation.status, 'pending'), - gt(credentialSetInvitation.expiresAt, new Date()) - ) - ) - - return NextResponse.json({ invitations }) - } catch (error) { - logger.error('Error fetching credential set invitations', error) - return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts deleted file mode 100644 index 160a5054739..00000000000 --- a/apps/sim/app/api/credential-sets/invite/[token]/route.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { - credentialSet, - credentialSetInvitation, - credentialSetMember, - organization, -} from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { normalizeEmail } from '@sim/utils/string' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { - acceptCredentialSetInvitationContract, - getCredentialSetInvitationContract, -} from '@/lib/api/contracts/credential-sets' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' - -const logger = createLogger('CredentialSetInviteToken') - -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ token: string }> }) => { - const parsed = await parseRequest(getCredentialSetInvitationContract, req, context) - if (!parsed.success) return parsed.response - const { token } = parsed.data.params - - const [invitation] = await db - .select({ - id: credentialSetInvitation.id, - credentialSetId: credentialSetInvitation.credentialSetId, - email: credentialSetInvitation.email, - status: credentialSetInvitation.status, - expiresAt: credentialSetInvitation.expiresAt, - credentialSetName: credentialSet.name, - providerId: credentialSet.providerId, - organizationId: credentialSet.organizationId, - organizationName: organization.name, - }) - .from(credentialSetInvitation) - .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) - .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) - .where(eq(credentialSetInvitation.token, token)) - .limit(1) - - if (!invitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - if (invitation.status !== 'pending') { - return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 }) - } - - if (new Date() > invitation.expiresAt) { - await db - .update(credentialSetInvitation) - .set({ status: 'expired' }) - .where(eq(credentialSetInvitation.id, invitation.id)) - - return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 }) - } - - return NextResponse.json({ - invitation: { - credentialSetName: invitation.credentialSetName, - organizationName: invitation.organizationName, - providerId: invitation.providerId, - email: invitation.email, - }, - }) - } -) - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ token: string }> }) => { - const parsed = await parseRequest(acceptCredentialSetInvitationContract, req, context) - if (!parsed.success) return parsed.response - const { token } = parsed.data.params - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - try { - const [invitationData] = await db - .select({ - id: credentialSetInvitation.id, - credentialSetId: credentialSetInvitation.credentialSetId, - email: credentialSetInvitation.email, - status: credentialSetInvitation.status, - expiresAt: credentialSetInvitation.expiresAt, - invitedBy: credentialSetInvitation.invitedBy, - credentialSetName: credentialSet.name, - providerId: credentialSet.providerId, - }) - .from(credentialSetInvitation) - .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) - .where(eq(credentialSetInvitation.token, token)) - .limit(1) - - if (!invitationData) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - const invitation = invitationData - - if (invitation.status !== 'pending') { - return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 }) - } - - if (new Date() > invitation.expiresAt) { - await db - .update(credentialSetInvitation) - .set({ status: 'expired' }) - .where(eq(credentialSetInvitation.id, invitation.id)) - - return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 }) - } - - if (invitation.email) { - const sessionEmail = session.user.email - if (!sessionEmail || normalizeEmail(sessionEmail) !== normalizeEmail(invitation.email)) { - logger.warn('Rejected credential set invitation accept due to email mismatch', { - invitationId: invitation.id, - credentialSetId: invitation.credentialSetId, - userId: session.user.id, - }) - return NextResponse.json( - { error: 'This invitation was sent to a different email address' }, - { status: 403 } - ) - } - } - - const existingMember = await db - .select() - .from(credentialSetMember) - .where( - and( - eq(credentialSetMember.credentialSetId, invitation.credentialSetId), - eq(credentialSetMember.userId, session.user.id) - ) - ) - .limit(1) - - if (existingMember.length > 0) { - return NextResponse.json( - { error: 'Already a member of this credential set' }, - { status: 409 } - ) - } - - const now = new Date() - const requestId = generateId().slice(0, 8) - - await db.transaction(async (tx) => { - await tx.insert(credentialSetMember).values({ - id: generateId(), - credentialSetId: invitation.credentialSetId, - userId: session.user.id, - status: 'active', - joinedAt: now, - invitedBy: invitation.invitedBy, - createdAt: now, - updatedAt: now, - }) - - await tx - .update(credentialSetInvitation) - .set({ - status: 'accepted', - acceptedAt: now, - acceptedByUserId: session.user.id, - }) - .where(eq(credentialSetInvitation.id, invitation.id)) - - if (invitation.email) { - await tx - .update(credentialSetInvitation) - .set({ - status: 'accepted', - acceptedAt: now, - acceptedByUserId: session.user.id, - }) - .where( - and( - eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId), - eq(credentialSetInvitation.email, invitation.email), - eq(credentialSetInvitation.status, 'pending') - ) - ) - } - }) - - // Runs after the membership commits: the sync performs external HTTP - // (OAuth refresh, provider unsubscribe) and must not hold a pooled - // connection. A sync failure must not fail the committed mutation — - // it self-heals on the next membership change/deploy. - try { - const syncResult = await syncAllWebhooksForCredentialSet( - invitation.credentialSetId, - requestId - ) - logger.info('Synced webhooks after member joined', { - credentialSetId: invitation.credentialSetId, - ...syncResult, - }) - } catch (syncError) { - logger.error('Webhook sync failed after invitation accept', { - credentialSetId: invitation.credentialSetId, - error: syncError, - }) - } - - logger.info('Accepted credential set invitation', { - invitationId: invitation.id, - credentialSetId: invitation.credentialSetId, - userId: session.user.id, - }) - - recordAudit({ - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_SET_INVITATION_ACCEPTED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: invitation.credentialSetId, - resourceName: invitation.credentialSetName, - description: `Accepted credential set invitation`, - metadata: { - invitationId: invitation.id, - credentialSetId: invitation.credentialSetId, - providerId: invitation.providerId, - credentialSetName: invitation.credentialSetName, - }, - request: req, - }) - - return NextResponse.json({ - success: true, - credentialSetId: invitation.credentialSetId, - providerId: invitation.providerId, - }) - } catch (error) { - logger.error('Error accepting invitation', error) - return NextResponse.json({ error: 'Failed to accept invitation' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts deleted file mode 100644 index 8ce556f5b5d..00000000000 --- a/apps/sim/app/api/credential-sets/memberships/route.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credentialSet, credentialSetMember, organization } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { leaveCredentialSetQuerySchema } from '@/lib/api/contracts/credential-sets' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' - -const logger = createLogger('CredentialSetMemberships') - -export const GET = withRouteHandler(async () => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const memberships = await db - .select({ - membershipId: credentialSetMember.id, - status: credentialSetMember.status, - joinedAt: credentialSetMember.joinedAt, - credentialSetId: credentialSet.id, - credentialSetName: credentialSet.name, - credentialSetDescription: credentialSet.description, - providerId: credentialSet.providerId, - organizationId: organization.id, - organizationName: organization.name, - }) - .from(credentialSetMember) - .innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id)) - .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) - .where(eq(credentialSetMember.userId, session.user.id)) - - return NextResponse.json({ memberships }) - } catch (error) { - logger.error('Error fetching credential set memberships', error) - return NextResponse.json({ error: 'Failed to fetch memberships' }, { status: 500 }) - } -}) - -/** - * Leave a credential set (self-revocation). - * Sets status to 'revoked' immediately (blocks execution), then syncs webhooks to clean up. - */ -export const DELETE = withRouteHandler(async (req: NextRequest) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(req.url) - const validation = leaveCredentialSetQuerySchema.safeParse({ - credentialSetId: searchParams.get('credentialSetId') ?? '', - }) - - if (!validation.success) { - return NextResponse.json( - { error: getValidationErrorMessage(validation.error) }, - { status: 400 } - ) - } - - const { credentialSetId } = validation.data - - try { - const requestId = generateId().slice(0, 8) - - await db.transaction(async (tx) => { - // Find and verify membership - const [membership] = await tx - .select() - .from(credentialSetMember) - .where( - and( - eq(credentialSetMember.credentialSetId, credentialSetId), - eq(credentialSetMember.userId, session.user.id) - ) - ) - .limit(1) - - if (!membership) { - throw new Error('Not a member of this credential set') - } - - if (membership.status === 'revoked') { - throw new Error('Already left this credential set') - } - - // Set status to 'revoked' - this immediately blocks credential from being used - await tx - .update(credentialSetMember) - .set({ - status: 'revoked', - updatedAt: new Date(), - }) - .where(eq(credentialSetMember.id, membership.id)) - }) - - // Runs after the revocation commits: the sync performs external HTTP - // (OAuth refresh, provider unsubscribe) and must not hold a pooled - // connection. A sync failure must not fail the committed mutation — - // it self-heals on the next membership change/deploy. - try { - const syncResult = await syncAllWebhooksForCredentialSet(credentialSetId, requestId) - logger.info('Synced webhooks after member left', { - credentialSetId, - userId: session.user.id, - ...syncResult, - }) - } catch (syncError) { - logger.error('Webhook sync failed after member left', { - credentialSetId, - userId: session.user.id, - error: syncError, - }) - } - - logger.info('User left credential set', { - credentialSetId, - userId: session.user.id, - }) - - recordAudit({ - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_SET_MEMBER_LEFT, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: credentialSetId, - description: `Left credential set`, - metadata: { credentialSetId }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - const message = getErrorMessage(error, 'Failed to leave credential set') - logger.error('Error leaving credential set', error) - return NextResponse.json({ error: message }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts deleted file mode 100644 index 0221ca44b93..00000000000 --- a/apps/sim/app/api/credential-sets/route.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credentialSet, credentialSetMember, member, organization, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, count, desc, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { - createCredentialSetContract, - listCredentialSetsQuerySchema, -} from '@/lib/api/contracts/credential-sets' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { hasCredentialSetsAccess } from '@/lib/billing' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CredentialSets') - -export const GET = withRouteHandler(async (req: Request) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { searchParams } = new URL(req.url) - const validation = listCredentialSetsQuerySchema.safeParse({ - organizationId: searchParams.get('organizationId') ?? '', - }) - - if (!validation.success) { - return NextResponse.json( - { error: getValidationErrorMessage(validation.error) }, - { status: 400 } - ) - } - - const { organizationId } = validation.data - - const membership = await db - .select({ id: member.id, role: member.role }) - .from(member) - .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId))) - .limit(1) - - if (membership.length === 0) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - const sets = await db - .select({ - id: credentialSet.id, - name: credentialSet.name, - description: credentialSet.description, - providerId: credentialSet.providerId, - createdBy: credentialSet.createdBy, - createdAt: credentialSet.createdAt, - updatedAt: credentialSet.updatedAt, - creatorName: user.name, - creatorEmail: user.email, - }) - .from(credentialSet) - .leftJoin(user, eq(credentialSet.createdBy, user.id)) - .where(eq(credentialSet.organizationId, organizationId)) - .orderBy(desc(credentialSet.createdAt)) - - const setsWithCounts = await Promise.all( - sets.map(async (set) => { - const [memberCount] = await db - .select({ count: count() }) - .from(credentialSetMember) - .where( - and( - eq(credentialSetMember.credentialSetId, set.id), - eq(credentialSetMember.status, 'active') - ) - ) - - return { - ...set, - memberCount: memberCount?.count ?? 0, - } - }) - ) - - return NextResponse.json({ credentialSets: setsWithCounts }) -}) - -export const POST = withRouteHandler(async (req: NextRequest) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - try { - const parsed = await parseRequest( - createCredentialSetContract, - req, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const { organizationId, name, description, providerId } = parsed.data.body - - const membership = await db - .select({ id: member.id, role: member.role }) - .from(member) - .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId))) - .limit(1) - - const role = membership[0]?.role - if (membership.length === 0 || (role !== 'admin' && role !== 'owner')) { - return NextResponse.json( - { error: 'Admin or owner permissions required to create credential sets' }, - { status: 403 } - ) - } - - // Check org existence and name uniqueness in parallel - const [orgExists, existingSet] = await Promise.all([ - db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1), - db - .select({ id: credentialSet.id }) - .from(credentialSet) - .where(and(eq(credentialSet.organizationId, organizationId), eq(credentialSet.name, name))) - .limit(1), - ]) - - if (orgExists.length === 0) { - return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) - } - - if (existingSet.length > 0) { - return NextResponse.json( - { error: 'A credential set with this name already exists' }, - { status: 409 } - ) - } - - const now = new Date() - const newCredentialSet = { - id: generateId(), - organizationId, - name, - description: description || null, - providerId, - createdBy: session.user.id, - createdAt: now, - updatedAt: now, - } - - await db.insert(credentialSet).values(newCredentialSet) - - logger.info('Created credential set', { - credentialSetId: newCredentialSet.id, - organizationId, - userId: session.user.id, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_CREATED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: newCredentialSet.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: name, - description: `Created credential set "${name}"`, - metadata: { organizationId, providerId, credentialSetName: name }, - request: req, - }) - - return NextResponse.json( - { - credentialSet: { - ...newCredentialSet, - creatorName: session.user.name ?? null, - creatorEmail: session.user.email ?? null, - memberCount: 0, - }, - }, - { status: 201 } - ) - } catch (error) { - logger.error('Error creating credential set', error) - return NextResponse.json({ error: 'Failed to create credential set' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/custom-blocks/route.ts b/apps/sim/app/api/custom-blocks/route.ts index c244ae88ad4..069751f97d4 100644 --- a/apps/sim/app/api/custom-blocks/route.ts +++ b/apps/sim/app/api/custom-blocks/route.ts @@ -17,11 +17,7 @@ import { listCustomBlocksWithInputs, publishCustomBlock, } from '@/lib/workflows/custom-blocks/operations' -import { - checkWorkspaceAccess, - getWorkspaceWithOwner, - hasWorkspaceAdminAccess, -} from '@/lib/workspaces/permissions/utils' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CustomBlocksAPI') @@ -84,18 +80,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userId = session.user.id const { workspaceId, workflowId, name, description, iconUrl, exposedOutputs } = parsed.data.body - if (!(await hasWorkspaceAdminAccess(userId, workspaceId))) { + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.canAdmin) { return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) } - const ws = await getWorkspaceWithOwner(workspaceId) - if (!ws?.organizationId) { + const organizationId = access.workspace?.organizationId + if (!organizationId) { return NextResponse.json( { error: 'Publishing a block requires the workspace to belong to an organization' }, { status: 400 } ) } - const organizationId = ws.organizationId if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) { return NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }) diff --git a/apps/sim/app/api/files/download/route.ts b/apps/sim/app/api/files/download/route.ts index 33f1ce61146..62bdbfe6e6a 100644 --- a/apps/sim/app/api/files/download/route.ts +++ b/apps/sim/app/api/files/download/route.ts @@ -4,8 +4,8 @@ import { fileDownloadContract } from '@/lib/api/contracts/storage-transfer' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { StorageContext } from '@/lib/uploads/config' import { hasCloudStorage } from '@/lib/uploads/core/storage-service' +import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' @@ -40,7 +40,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { key, name, isExecutionFile, context, url } = parsed.data.body + const { key, name, url } = parsed.data.body if (!key) { return createErrorResponse(new Error('File key is required'), 400) @@ -58,12 +58,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - let storageContext: StorageContext | 'general' | undefined = context - - if (isExecutionFile && !context) { - storageContext = 'execution' - logger.info(`Using execution context for file: ${key}`) - } + // Derive context from the trusted key prefix, mirroring the serve route this URL + // delegates to, which re-derives context from the key and ignores any client-supplied value. + const storageContext = inferContextFromKey(key) const hasAccess = await verifyFileAccess( key, @@ -79,10 +76,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const { getBaseUrl } = await import('@/lib/core/utils/urls') - const contextQuery = storageContext ? `?context=${storageContext}` : '' - const downloadUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(key)}${contextQuery}` + const downloadUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(key)}?context=${storageContext}` - logger.info(`Generated download URL for ${storageContext ?? 'inferred'} file: ${key}`) + logger.info(`Generated download URL for ${storageContext} file: ${key}`) return NextResponse.json({ downloadUrl, diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index b925a366033..033f180818a 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -13,7 +13,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isSupportedFileType, parseFile } from '@/lib/file-parsers' -import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads' +import { isUsingCloudStorage, StorageService } from '@/lib/uploads' import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' import { ExternalUrlValidationError, @@ -303,7 +303,6 @@ async function parseFileSingle( return handleCloudFile( filePath, fileType, - undefined, userId, executionContext, maxDownloadBytes, @@ -329,7 +328,6 @@ async function parseFileSingle( return handleCloudFile( filePath, fileType, - undefined, userId, executionContext, maxDownloadBytes, @@ -608,7 +606,6 @@ async function handleExternalUrl( async function handleCloudFile( filePath: string, fileType: string, - explicitContext: string | undefined, userId: string, executionContext?: ExecutionContext, maxDownloadBytes = MAX_DOWNLOAD_SIZE_BYTES, @@ -619,7 +616,7 @@ async function handleCloudFile( logger.info('Extracted cloud key:', cloudKey) - const context = (explicitContext as StorageContext) || inferContextFromKey(cloudKey) + const context = inferContextFromKey(cloudKey) const hasAccess = await verifyFileAccess( cloudKey, @@ -658,7 +655,7 @@ async function handleCloudFile( maxBytes: maxDownloadBytes, }) logger.info( - `Downloaded file from ${context} storage (${explicitContext ? 'explicit' : 'inferred'}): ${cloudKey}, size: ${fileBuffer.length} bytes` + `Downloaded file from ${context} storage: ${cloudKey}, size: ${fileBuffer.length} bytes` ) const filename = originalFilename || cloudKey.split('/').pop() || cloudKey diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index d9206b0caeb..6310d7f4696 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -1,13 +1,11 @@ -import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createFolderContract, listFoldersContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listFoldersForWorkspace } from '@/lib/folders/queries' import { captureServerEvent } from '@/lib/posthog/server' import { performCreateFolder } from '@/lib/workflows/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -44,16 +42,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 }) } - const archivedFilter = - scope === 'archived' - ? isNotNull(workflowFolder.archivedAt) - : isNull(workflowFolder.archivedAt) - - const folders = await db - .select() - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), archivedFilter)) - .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)) + const folders = await listFoldersForWorkspace(workspaceId, scope) return NextResponse.json({ folders }) } catch (error) { diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index 133d553388f..7c4111d46d1 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -1,9 +1,10 @@ import { db } from '@sim/db' import { knowledgeBase, knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray, isNull, lte } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, lte } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine' @@ -12,6 +13,15 @@ export const dynamic = 'force-dynamic' const logger = createLogger('ConnectorSyncSchedulerAPI') +/** + * Per-tick cap on sync dispatches. Ordered by oldest `nextSyncAt` first so + * connectors beyond the cap are picked up by the next tick, not starved. + */ +const MAX_DISPATCHES_PER_TICK = 200 + +/** Each dispatch does a joined SELECT + conditional UPDATE against the shared pool. */ +const DISPATCH_CONCURRENCY = 10 + /** * Cron endpoint that checks for connectors due for sync and dispatches sync jobs. * Should be called every 5 minutes by an external cron service. @@ -71,6 +81,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { isNull(knowledgeBase.deletedAt) ) ) + .orderBy(asc(knowledgeConnector.nextSyncAt)) + .limit(MAX_DISPATCHES_PER_TICK) logger.info(`[${requestId}] Found ${dueConnectors.length} connectors due for sync`) @@ -82,11 +94,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } - for (const connector of dueConnectors) { + await mapWithConcurrency(dueConnectors, DISPATCH_CONCURRENCY, (connector) => dispatchSync(connector.id, { requestId }).catch((error) => { logger.error(`[${requestId}] Failed to dispatch sync for connector ${connector.id}`, error) }) - } + ) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/tools/secrets_manager/describe-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/describe-secret/route.ts new file mode 100644 index 00000000000..793f8ce802b --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/describe-secret/route.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSecretsManagerDescribeSecretContract } from '@/lib/api/contracts/tools/aws/secrets-manager-describe-secret' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, describeSecret } from '../utils' + +const logger = createLogger('SecretsManagerDescribeSecretAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerDescribeSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Describing secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await describeSecret(client, params.secretId) + + logger.info(`[${requestId}] Described secret: ${result.name}`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to describe secret:`, error) + + return NextResponse.json( + { error: `Failed to describe secret: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/restore-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/restore-secret/route.ts new file mode 100644 index 00000000000..e73da9582a9 --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/restore-secret/route.ts @@ -0,0 +1,58 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSecretsManagerRestoreSecretContract } from '@/lib/api/contracts/tools/aws/secrets-manager-restore-secret' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, restoreSecret } from '../utils' + +const logger = createLogger('SecretsManagerRestoreSecretAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerRestoreSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Restoring secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await restoreSecret(client, params.secretId) + + logger.info(`[${requestId}] Restored secret: ${result.name}`) + + return NextResponse.json({ + message: `Secret "${result.name}" restored successfully`, + ...result, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to restore secret:`, error) + + return NextResponse.json( + { error: `Failed to restore secret: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/rotate-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/rotate-secret/route.ts new file mode 100644 index 00000000000..3a0718c4b90 --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/rotate-secret/route.ts @@ -0,0 +1,66 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSecretsManagerRotateSecretContract } from '@/lib/api/contracts/tools/aws/secrets-manager-rotate-secret' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, rotateSecret } from '../utils' + +const logger = createLogger('SecretsManagerRotateSecretAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerRotateSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Rotating secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await rotateSecret( + client, + params.secretId, + params.clientRequestToken, + params.rotationLambdaARN, + { + automaticallyAfterDays: params.automaticallyAfterDays, + duration: params.duration, + scheduleExpression: params.scheduleExpression, + }, + params.rotateImmediately + ) + + logger.info(`[${requestId}] Rotation started for secret: ${result.name}`) + + return NextResponse.json({ + message: `Rotation started for secret "${result.name}"`, + ...result, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to rotate secret:`, error) + + return NextResponse.json({ error: `Failed to rotate secret: ${errorMessage}` }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/tag-resource/route.ts b/apps/sim/app/api/tools/secrets_manager/tag-resource/route.ts new file mode 100644 index 00000000000..6c4bec10989 --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/tag-resource/route.ts @@ -0,0 +1,59 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSecretsManagerTagResourceContract } from '@/lib/api/contracts/tools/aws/secrets-manager-tag-resource' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, tagResource } from '../utils' + +const logger = createLogger('SecretsManagerTagResourceAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerTagResourceContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Tagging secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await tagResource( + client, + params.secretId, + params.tags.map((t) => ({ Key: t.key, Value: t.value })) + ) + + logger.info(`[${requestId}] Tagged secret: ${result.name}`) + + return NextResponse.json({ + message: `Secret "${result.name}" tagged successfully`, + ...result, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to tag secret:`, error) + + return NextResponse.json({ error: `Failed to tag secret: ${errorMessage}` }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/untag-resource/route.ts b/apps/sim/app/api/tools/secrets_manager/untag-resource/route.ts new file mode 100644 index 00000000000..9300e81008f --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/untag-resource/route.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSecretsManagerUntagResourceContract } from '@/lib/api/contracts/tools/aws/secrets-manager-untag-resource' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, untagResource } from '../utils' + +const logger = createLogger('SecretsManagerUntagResourceAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerUntagResourceContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Untagging secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await untagResource(client, params.secretId, params.tagKeys) + + logger.info(`[${requestId}] Untagged secret: ${result.name}`) + + return NextResponse.json({ + message: `Secret "${result.name}" untagged successfully`, + ...result, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to untag secret:`, error) + + return NextResponse.json({ error: `Failed to untag secret: ${errorMessage}` }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/utils.ts b/apps/sim/app/api/tools/secrets_manager/utils.ts index dacbbc30be1..fc18d95b2fd 100644 --- a/apps/sim/app/api/tools/secrets_manager/utils.ts +++ b/apps/sim/app/api/tools/secrets_manager/utils.ts @@ -1,14 +1,28 @@ -import type { SecretListEntry, Tag } from '@aws-sdk/client-secrets-manager' +import type { RotationRulesType, SecretListEntry, Tag } from '@aws-sdk/client-secrets-manager' import { CreateSecretCommand, DeleteSecretCommand, + DescribeSecretCommand, GetSecretValueCommand, ListSecretsCommand, + RestoreSecretCommand, + RotateSecretCommand, SecretsManagerClient, + TagResourceCommand, + UntagResourceCommand, UpdateSecretCommand, } from '@aws-sdk/client-secrets-manager' import type { SecretsManagerConnectionConfig } from '@/tools/secrets_manager/types' +function mapRotationRules(rules: RotationRulesType | undefined) { + if (!rules) return null + return { + automaticallyAfterDays: rules.AutomaticallyAfterDays ?? null, + duration: rules.Duration ?? null, + scheduleExpression: rules.ScheduleExpression ?? null, + } +} + export function createSecretsManagerClient( config: SecretsManagerConnectionConfig ): SecretsManagerClient { @@ -71,6 +85,11 @@ export async function listSecrets( lastAccessedDate: secret.LastAccessedDate?.toISOString() ?? null, rotationEnabled: secret.RotationEnabled ?? false, tags: secret.Tags?.map((t: Tag) => ({ key: t.Key ?? '', value: t.Value ?? '' })) ?? [], + rotationRules: mapRotationRules(secret.RotationRules), + lastRotatedDate: secret.LastRotatedDate?.toISOString() ?? null, + nextRotationDate: secret.NextRotationDate?.toISOString() ?? null, + deletedDate: secret.DeletedDate?.toISOString() ?? null, + secretVersionsToStages: secret.SecretVersionsToStages ?? null, })) return { @@ -139,3 +158,109 @@ export async function deleteSecret( deletionDate: response.DeletionDate?.toISOString() ?? null, } } + +export async function describeSecret(client: SecretsManagerClient, secretId: string) { + const command = new DescribeSecretCommand({ SecretId: secretId }) + const response = await client.send(command) + + return { + name: response.Name ?? '', + arn: response.ARN ?? '', + description: response.Description ?? null, + kmsKeyId: response.KmsKeyId ?? null, + rotationEnabled: response.RotationEnabled ?? false, + rotationLambdaARN: response.RotationLambdaARN ?? null, + rotationRules: mapRotationRules(response.RotationRules), + lastRotatedDate: response.LastRotatedDate?.toISOString() ?? null, + lastChangedDate: response.LastChangedDate?.toISOString() ?? null, + lastAccessedDate: response.LastAccessedDate?.toISOString() ?? null, + deletedDate: response.DeletedDate?.toISOString() ?? null, + nextRotationDate: response.NextRotationDate?.toISOString() ?? null, + tags: response.Tags?.map((t: Tag) => ({ key: t.Key ?? '', value: t.Value ?? '' })) ?? [], + versionIdsToStages: response.VersionIdsToStages ?? null, + owningService: response.OwningService ?? null, + createdDate: response.CreatedDate?.toISOString() ?? null, + primaryRegion: response.PrimaryRegion ?? null, + replicationStatus: + response.ReplicationStatus?.map((r) => ({ + region: r.Region ?? '', + kmsKeyId: r.KmsKeyId ?? null, + status: r.Status ?? null, + statusMessage: r.StatusMessage ?? null, + lastAccessedDate: r.LastAccessedDate?.toISOString() ?? null, + })) ?? [], + } +} + +export async function tagResource(client: SecretsManagerClient, secretId: string, tags: Tag[]) { + const command = new TagResourceCommand({ SecretId: secretId, Tags: tags }) + await client.send(command) + return { name: secretId } +} + +export async function untagResource( + client: SecretsManagerClient, + secretId: string, + tagKeys: string[] +) { + const command = new UntagResourceCommand({ SecretId: secretId, TagKeys: tagKeys }) + await client.send(command) + return { name: secretId } +} + +export async function restoreSecret(client: SecretsManagerClient, secretId: string) { + const command = new RestoreSecretCommand({ SecretId: secretId }) + const response = await client.send(command) + return { + name: response.Name ?? '', + arn: response.ARN ?? '', + } +} + +export async function rotateSecret( + client: SecretsManagerClient, + secretId: string, + clientRequestToken?: string | null, + rotationLambdaARN?: string | null, + rotationRules?: { + automaticallyAfterDays?: number | null + duration?: string | null + scheduleExpression?: string | null + } | null, + rotateImmediately?: boolean | null +) { + const hasRotationRules = Boolean( + rotationRules?.automaticallyAfterDays || + rotationRules?.duration || + rotationRules?.scheduleExpression + ) + + const command = new RotateSecretCommand({ + SecretId: secretId, + ...(clientRequestToken ? { ClientRequestToken: clientRequestToken } : {}), + ...(rotationLambdaARN ? { RotationLambdaARN: rotationLambdaARN } : {}), + ...(hasRotationRules + ? { + RotationRules: { + ...(rotationRules?.automaticallyAfterDays + ? { AutomaticallyAfterDays: rotationRules.automaticallyAfterDays } + : {}), + ...(rotationRules?.duration ? { Duration: rotationRules.duration } : {}), + ...(rotationRules?.scheduleExpression + ? { ScheduleExpression: rotationRules.scheduleExpression } + : {}), + }, + } + : {}), + ...(rotateImmediately === undefined || rotateImmediately === null + ? {} + : { RotateImmediately: rotateImmediately }), + }) + + const response = await client.send(command) + return { + name: response.Name ?? '', + arn: response.ARN ?? '', + versionId: response.VersionId ?? '', + } +} diff --git a/apps/sim/app/api/tools/ses/create-configuration-set/route.ts b/apps/sim/app/api/tools/ses/create-configuration-set/route.ts new file mode 100644 index 00000000000..f905aff437f --- /dev/null +++ b/apps/sim/app/api/tools/ses/create-configuration-set/route.ts @@ -0,0 +1,69 @@ +import type { SuppressionListReason } from '@aws-sdk/client-sesv2' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesCreateConfigurationSetContract } from '@/lib/api/contracts/tools/aws/ses-create-configuration-set' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createConfigurationSet, createSESClient } from '../utils' + +const logger = createLogger('SESCreateConfigurationSetAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesCreateConfigurationSetContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Creating SES configuration set') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const suppressedReasons = params.suppressedReasons + ? (params.suppressedReasons + .split(',') + .map((r) => r.trim()) + .filter(Boolean) as SuppressionListReason[]) + : null + + const result = await createConfigurationSet(client, { + configurationSetName: params.configurationSetName, + customRedirectDomain: params.customRedirectDomain, + httpsPolicy: params.httpsPolicy, + tlsPolicy: params.tlsPolicy, + sendingPoolName: params.sendingPoolName, + reputationMetricsEnabled: params.reputationMetricsEnabled, + sendingEnabled: params.sendingEnabled, + suppressedReasons, + tags: params.tags, + }) + + logger.info(`Created configuration set '${params.configurationSetName}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to create configuration set:', error) + + return NextResponse.json( + { error: `Failed to create configuration set: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/create-email-identity/route.ts b/apps/sim/app/api/tools/ses/create-email-identity/route.ts new file mode 100644 index 00000000000..9f58228b173 --- /dev/null +++ b/apps/sim/app/api/tools/ses/create-email-identity/route.ts @@ -0,0 +1,56 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesCreateEmailIdentityContract } from '@/lib/api/contracts/tools/aws/ses-create-email-identity' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createEmailIdentity, createSESClient } from '../utils' + +const logger = createLogger('SESCreateEmailIdentityAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesCreateEmailIdentityContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Creating SES email identity') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await createEmailIdentity(client, { + emailIdentity: params.emailIdentity, + dkimSigningAttributes: params.dkimSigningAttributes, + tags: params.tags, + configurationSetName: params.configurationSetName, + }) + + logger.info(`Created email identity '${params.emailIdentity}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to create email identity:', error) + + return NextResponse.json( + { error: `Failed to create email identity: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/delete-email-identity/route.ts b/apps/sim/app/api/tools/ses/delete-email-identity/route.ts new file mode 100644 index 00000000000..a185fc769ca --- /dev/null +++ b/apps/sim/app/api/tools/ses/delete-email-identity/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesDeleteEmailIdentityContract } from '@/lib/api/contracts/tools/aws/ses-delete-email-identity' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, deleteEmailIdentity } from '../utils' + +const logger = createLogger('SESDeleteEmailIdentityAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesDeleteEmailIdentityContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Deleting SES email identity') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await deleteEmailIdentity(client, params.emailIdentity) + + logger.info(`Deleted email identity '${params.emailIdentity}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to delete email identity:', error) + + return NextResponse.json( + { error: `Failed to delete email identity: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/delete-suppressed-destination/route.ts b/apps/sim/app/api/tools/ses/delete-suppressed-destination/route.ts new file mode 100644 index 00000000000..c0a047279e6 --- /dev/null +++ b/apps/sim/app/api/tools/ses/delete-suppressed-destination/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesDeleteSuppressedDestinationContract } from '@/lib/api/contracts/tools/aws/ses-delete-suppressed-destination' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, deleteSuppressedDestination } from '../utils' + +const logger = createLogger('SESDeleteSuppressedDestinationAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesDeleteSuppressedDestinationContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Removing email address from SES suppression list') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await deleteSuppressedDestination(client, params.emailAddress) + + logger.info('Removed email address from suppression list') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to remove suppressed destination:', error) + + return NextResponse.json( + { error: `Failed to remove suppressed destination: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/get-email-identity/route.ts b/apps/sim/app/api/tools/ses/get-email-identity/route.ts new file mode 100644 index 00000000000..c13a11c6fe9 --- /dev/null +++ b/apps/sim/app/api/tools/ses/get-email-identity/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesGetEmailIdentityContract } from '@/lib/api/contracts/tools/aws/ses-get-email-identity' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, getEmailIdentity } from '../utils' + +const logger = createLogger('SESGetEmailIdentityAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesGetEmailIdentityContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Fetching SES email identity') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await getEmailIdentity(client, params.emailIdentity) + + logger.info(`Fetched email identity '${params.emailIdentity}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to get email identity:', error) + + return NextResponse.json( + { error: `Failed to get email identity: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/get-suppressed-destination/route.ts b/apps/sim/app/api/tools/ses/get-suppressed-destination/route.ts new file mode 100644 index 00000000000..71022496733 --- /dev/null +++ b/apps/sim/app/api/tools/ses/get-suppressed-destination/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesGetSuppressedDestinationContract } from '@/lib/api/contracts/tools/aws/ses-get-suppressed-destination' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, getSuppressedDestination } from '../utils' + +const logger = createLogger('SESGetSuppressedDestinationAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesGetSuppressedDestinationContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Fetching SES suppressed destination') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await getSuppressedDestination(client, params.emailAddress) + + logger.info('Fetched suppressed destination') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to get suppressed destination:', error) + + return NextResponse.json( + { error: `Failed to get suppressed destination: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/list-suppressed-destinations/route.ts b/apps/sim/app/api/tools/ses/list-suppressed-destinations/route.ts new file mode 100644 index 00000000000..639704da110 --- /dev/null +++ b/apps/sim/app/api/tools/ses/list-suppressed-destinations/route.ts @@ -0,0 +1,80 @@ +import type { SuppressionListReason } from '@aws-sdk/client-sesv2' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesListSuppressedDestinationsContract } from '@/lib/api/contracts/tools/aws/ses-list-suppressed-destinations' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, listSuppressedDestinations } from '../utils' + +const logger = createLogger('SESListSuppressedDestinationsAPI') + +const VALID_SUPPRESSION_REASONS: SuppressionListReason[] = ['BOUNCE', 'COMPLAINT'] + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesListSuppressedDestinationsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + let reasons: SuppressionListReason[] | null = null + if (params.reasons) { + const candidates = params.reasons + .split(',') + .map((r) => r.trim()) + .filter(Boolean) + const invalid = candidates.filter( + (r) => !VALID_SUPPRESSION_REASONS.includes(r as SuppressionListReason) + ) + if (invalid.length > 0) { + return NextResponse.json( + { + error: `Invalid suppression reason(s): ${invalid.join(', ')}. Must be one of: ${VALID_SUPPRESSION_REASONS.join(', ')}`, + }, + { status: 400 } + ) + } + reasons = candidates as SuppressionListReason[] + } + + logger.info('Listing SES suppressed destinations') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await listSuppressedDestinations(client, { + reasons, + startDate: params.startDate ? new Date(params.startDate) : null, + endDate: params.endDate ? new Date(params.endDate) : null, + pageSize: params.pageSize, + nextToken: params.nextToken, + }) + + logger.info(`Listed ${result.count} suppressed destinations`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to list suppressed destinations:', error) + + return NextResponse.json( + { error: `Failed to list suppressed destinations: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/put-suppressed-destination/route.ts b/apps/sim/app/api/tools/ses/put-suppressed-destination/route.ts new file mode 100644 index 00000000000..453ba387893 --- /dev/null +++ b/apps/sim/app/api/tools/ses/put-suppressed-destination/route.ts @@ -0,0 +1,54 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesPutSuppressedDestinationContract } from '@/lib/api/contracts/tools/aws/ses-put-suppressed-destination' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, putSuppressedDestination } from '../utils' + +const logger = createLogger('SESPutSuppressedDestinationAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesPutSuppressedDestinationContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Adding email address to SES suppression list') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await putSuppressedDestination(client, { + emailAddress: params.emailAddress, + reason: params.reason, + }) + + logger.info('Added email address to suppression list') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to add suppressed destination:', error) + + return NextResponse.json( + { error: `Failed to add suppressed destination: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/send-custom-verification-email/route.ts b/apps/sim/app/api/tools/ses/send-custom-verification-email/route.ts new file mode 100644 index 00000000000..dd4bf5387f9 --- /dev/null +++ b/apps/sim/app/api/tools/ses/send-custom-verification-email/route.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesSendCustomVerificationEmailContract } from '@/lib/api/contracts/tools/aws/ses-send-custom-verification-email' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, sendCustomVerificationEmail } from '../utils' + +const logger = createLogger('SESSendCustomVerificationEmailAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesSendCustomVerificationEmailContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Sending SES custom verification email') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await sendCustomVerificationEmail(client, { + emailAddress: params.emailAddress, + templateName: params.templateName, + configurationSetName: params.configurationSetName, + }) + + logger.info(`Sent custom verification email to '${params.emailAddress}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to send custom verification email:', error) + + return NextResponse.json( + { error: `Failed to send custom verification email: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/update-template/route.ts b/apps/sim/app/api/tools/ses/update-template/route.ts new file mode 100644 index 00000000000..4bebcff2100 --- /dev/null +++ b/apps/sim/app/api/tools/ses/update-template/route.ts @@ -0,0 +1,56 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesUpdateTemplateContract } from '@/lib/api/contracts/tools/aws/ses-update-template' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, updateTemplate } from '../utils' + +const logger = createLogger('SESUpdateTemplateAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesUpdateTemplateContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Updating SES email template') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await updateTemplate(client, { + templateName: params.templateName, + subjectPart: params.subjectPart, + textPart: params.textPart, + htmlPart: params.htmlPart, + }) + + logger.info(`Updated template '${params.templateName}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to update template:', error) + + return NextResponse.json( + { error: `Failed to update template: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/utils.ts b/apps/sim/app/api/tools/ses/utils.ts index 8e15e2d3d84..62fec2b74a0 100644 --- a/apps/sim/app/api/tools/ses/utils.ts +++ b/apps/sim/app/api/tools/ses/utils.ts @@ -1,13 +1,25 @@ import { + CreateConfigurationSetCommand, + CreateEmailIdentityCommand, CreateEmailTemplateCommand, + DeleteEmailIdentityCommand, DeleteEmailTemplateCommand, + DeleteSuppressedDestinationCommand, GetAccountCommand, + GetEmailIdentityCommand, GetEmailTemplateCommand, + GetSuppressedDestinationCommand, ListEmailIdentitiesCommand, ListEmailTemplatesCommand, + ListSuppressedDestinationsCommand, + PutSuppressedDestinationCommand, SESv2Client, SendBulkEmailCommand, + SendCustomVerificationEmailCommand, SendEmailCommand, + type SuppressionListReason, + type TlsPolicy, + UpdateEmailTemplateCommand, } from '@aws-sdk/client-sesv2' import { z } from 'zod' import type { SESConnectionConfig } from '@/tools/ses/types' @@ -268,3 +280,284 @@ export async function deleteTemplate(client: SESv2Client, templateName: string) message: `Template '${templateName}' deleted successfully`, } } + +export async function updateTemplate( + client: SESv2Client, + params: { + templateName: string + subjectPart: string + textPart?: string | null + htmlPart?: string | null + } +) { + const command = new UpdateEmailTemplateCommand({ + TemplateName: params.templateName, + TemplateContent: { + Subject: params.subjectPart, + ...(params.textPart ? { Text: params.textPart } : {}), + ...(params.htmlPart ? { Html: params.htmlPart } : {}), + }, + }) + + await client.send(command) + + return { + message: `Template '${params.templateName}' updated successfully`, + } +} + +export async function putSuppressedDestination( + client: SESv2Client, + params: { emailAddress: string; reason: SuppressionListReason } +) { + const command = new PutSuppressedDestinationCommand({ + EmailAddress: params.emailAddress, + Reason: params.reason, + }) + + await client.send(command) + + return { + message: `Email address '${params.emailAddress}' added to the suppression list`, + } +} + +export async function deleteSuppressedDestination(client: SESv2Client, emailAddress: string) { + const command = new DeleteSuppressedDestinationCommand({ EmailAddress: emailAddress }) + await client.send(command) + + return { + message: `Email address '${emailAddress}' removed from the suppression list`, + } +} + +export async function getSuppressedDestination(client: SESv2Client, emailAddress: string) { + const command = new GetSuppressedDestinationCommand({ EmailAddress: emailAddress }) + const response = await client.send(command) + const destination = response.SuppressedDestination + + return { + emailAddress: destination?.EmailAddress ?? emailAddress, + reason: destination?.Reason ?? '', + lastUpdateTime: destination?.LastUpdateTime?.toISOString() ?? null, + messageId: destination?.Attributes?.MessageId ?? null, + feedbackId: destination?.Attributes?.FeedbackId ?? null, + } +} + +export async function listSuppressedDestinations( + client: SESv2Client, + params: { + reasons?: SuppressionListReason[] | null + startDate?: Date | null + endDate?: Date | null + pageSize?: number | null + nextToken?: string | null + } +) { + const command = new ListSuppressedDestinationsCommand({ + ...(params.reasons?.length ? { Reasons: params.reasons } : {}), + ...(params.startDate ? { StartDate: params.startDate } : {}), + ...(params.endDate ? { EndDate: params.endDate } : {}), + ...(params.pageSize != null ? { PageSize: params.pageSize } : {}), + ...(params.nextToken ? { NextToken: params.nextToken } : {}), + }) + + const response = await client.send(command) + + const destinations = (response.SuppressedDestinationSummaries ?? []).map((d) => ({ + emailAddress: d.EmailAddress ?? '', + reason: d.Reason ?? '', + lastUpdateTime: d.LastUpdateTime?.toISOString() ?? null, + })) + + return { + destinations, + nextToken: response.NextToken ?? null, + count: destinations.length, + } +} + +export async function createEmailIdentity( + client: SESv2Client, + params: { + emailIdentity: string + dkimSigningAttributes?: { + domainSigningSelector?: string + domainSigningPrivateKey?: string + nextSigningKeyLength?: 'RSA_1024_BIT' | 'RSA_2048_BIT' + } | null + tags?: Array<{ key: string; value: string }> | null + configurationSetName?: string | null + } +) { + const command = new CreateEmailIdentityCommand({ + EmailIdentity: params.emailIdentity, + ...(params.dkimSigningAttributes + ? { + DkimSigningAttributes: { + ...(params.dkimSigningAttributes.domainSigningSelector + ? { DomainSigningSelector: params.dkimSigningAttributes.domainSigningSelector } + : {}), + ...(params.dkimSigningAttributes.domainSigningPrivateKey + ? { DomainSigningPrivateKey: params.dkimSigningAttributes.domainSigningPrivateKey } + : {}), + ...(params.dkimSigningAttributes.nextSigningKeyLength + ? { NextSigningKeyLength: params.dkimSigningAttributes.nextSigningKeyLength } + : {}), + }, + } + : {}), + ...(params.tags?.length + ? { Tags: params.tags.map((t) => ({ Key: t.key, Value: t.value })) } + : {}), + ...(params.configurationSetName ? { ConfigurationSetName: params.configurationSetName } : {}), + }) + + const response = await client.send(command) + + return { + identityType: response.IdentityType ?? '', + verifiedForSendingStatus: response.VerifiedForSendingStatus ?? false, + dkimAttributes: response.DkimAttributes + ? { + signingEnabled: response.DkimAttributes.SigningEnabled ?? null, + status: response.DkimAttributes.Status ?? null, + tokens: response.DkimAttributes.Tokens ?? [], + signingAttributesOrigin: response.DkimAttributes.SigningAttributesOrigin ?? null, + nextSigningKeyLength: response.DkimAttributes.NextSigningKeyLength ?? null, + currentSigningKeyLength: response.DkimAttributes.CurrentSigningKeyLength ?? null, + lastKeyGenerationTimestamp: + response.DkimAttributes.LastKeyGenerationTimestamp?.toISOString() ?? null, + signingHostedZone: response.DkimAttributes.SigningHostedZone ?? null, + } + : null, + } +} + +export async function deleteEmailIdentity(client: SESv2Client, emailIdentity: string) { + const command = new DeleteEmailIdentityCommand({ EmailIdentity: emailIdentity }) + await client.send(command) + + return { + message: `Email identity '${emailIdentity}' deleted successfully`, + } +} + +export async function getEmailIdentity(client: SESv2Client, emailIdentity: string) { + const command = new GetEmailIdentityCommand({ EmailIdentity: emailIdentity }) + const response = await client.send(command) + + return { + identityType: response.IdentityType ?? '', + verifiedForSendingStatus: response.VerifiedForSendingStatus ?? false, + verificationStatus: response.VerificationStatus ?? null, + feedbackForwardingStatus: response.FeedbackForwardingStatus ?? null, + configurationSetName: response.ConfigurationSetName ?? null, + dkimAttributes: response.DkimAttributes + ? { + signingEnabled: response.DkimAttributes.SigningEnabled ?? null, + status: response.DkimAttributes.Status ?? null, + tokens: response.DkimAttributes.Tokens ?? [], + signingAttributesOrigin: response.DkimAttributes.SigningAttributesOrigin ?? null, + nextSigningKeyLength: response.DkimAttributes.NextSigningKeyLength ?? null, + currentSigningKeyLength: response.DkimAttributes.CurrentSigningKeyLength ?? null, + lastKeyGenerationTimestamp: + response.DkimAttributes.LastKeyGenerationTimestamp?.toISOString() ?? null, + signingHostedZone: response.DkimAttributes.SigningHostedZone ?? null, + } + : null, + mailFromAttributes: response.MailFromAttributes + ? { + mailFromDomain: response.MailFromAttributes.MailFromDomain ?? null, + mailFromDomainStatus: response.MailFromAttributes.MailFromDomainStatus ?? null, + behaviorOnMxFailure: response.MailFromAttributes.BehaviorOnMxFailure ?? null, + } + : null, + policies: response.Policies ?? null, + tags: (response.Tags ?? []).map((t) => ({ key: t.Key ?? '', value: t.Value ?? '' })), + verificationInfo: response.VerificationInfo + ? { + errorType: response.VerificationInfo.ErrorType ?? null, + lastCheckedTimestamp: + response.VerificationInfo.LastCheckedTimestamp?.toISOString() ?? null, + lastSuccessTimestamp: + response.VerificationInfo.LastSuccessTimestamp?.toISOString() ?? null, + } + : null, + } +} + +export async function createConfigurationSet( + client: SESv2Client, + params: { + configurationSetName: string + customRedirectDomain?: string | null + httpsPolicy?: 'REQUIRE' | 'REQUIRE_OPEN_ONLY' | 'OPTIONAL' | null + tlsPolicy?: TlsPolicy | null + sendingPoolName?: string | null + reputationMetricsEnabled?: boolean | null + sendingEnabled?: boolean | null + suppressedReasons?: SuppressionListReason[] | null + tags?: Array<{ key: string; value: string }> | null + } +) { + const command = new CreateConfigurationSetCommand({ + ConfigurationSetName: params.configurationSetName, + ...(params.customRedirectDomain + ? { + TrackingOptions: { + CustomRedirectDomain: params.customRedirectDomain, + ...(params.httpsPolicy ? { HttpsPolicy: params.httpsPolicy } : {}), + }, + } + : {}), + ...(params.tlsPolicy || params.sendingPoolName + ? { + DeliveryOptions: { + ...(params.tlsPolicy ? { TlsPolicy: params.tlsPolicy } : {}), + ...(params.sendingPoolName ? { SendingPoolName: params.sendingPoolName } : {}), + }, + } + : {}), + ...(params.reputationMetricsEnabled != null + ? { ReputationOptions: { ReputationMetricsEnabled: params.reputationMetricsEnabled } } + : {}), + ...(params.sendingEnabled != null + ? { SendingOptions: { SendingEnabled: params.sendingEnabled } } + : {}), + ...(params.suppressedReasons?.length + ? { SuppressionOptions: { SuppressedReasons: params.suppressedReasons } } + : {}), + ...(params.tags?.length + ? { Tags: params.tags.map((t) => ({ Key: t.key, Value: t.value })) } + : {}), + }) + + await client.send(command) + + return { + message: `Configuration set '${params.configurationSetName}' created successfully`, + } +} + +export async function sendCustomVerificationEmail( + client: SESv2Client, + params: { + emailAddress: string + templateName: string + configurationSetName?: string | null + } +) { + const command = new SendCustomVerificationEmailCommand({ + EmailAddress: params.emailAddress, + TemplateName: params.templateName, + ...(params.configurationSetName ? { ConfigurationSetName: params.configurationSetName } : {}), + }) + + const response = await client.send(command) + + return { + messageId: response.MessageId ?? '', + } +} diff --git a/apps/sim/app/api/tools/sts/assume-role-with-saml/route.ts b/apps/sim/app/api/tools/sts/assume-role-with-saml/route.ts new file mode 100644 index 00000000000..d8762ee4e52 --- /dev/null +++ b/apps/sim/app/api/tools/sts/assume-role-with-saml/route.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsStsAssumeRoleWithSAMLContract } from '@/lib/api/contracts/tools/aws/sts-assume-role-with-saml' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { assumeRoleWithSAML, createUnauthenticatedSTSClient } from '../utils' + +const logger = createLogger('STSAssumeRoleWithSAMLAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsStsAssumeRoleWithSAMLContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`Assuming role ${params.roleArn} with SAML`) + + const client = createUnauthenticatedSTSClient(params.region) + + try { + const result = await assumeRoleWithSAML( + client, + params.roleArn, + params.principalArn, + params.samlAssertion, + params.policyArns, + params.policy, + params.durationSeconds + ) + + logger.info('Role assumed successfully with SAML') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to assume role with SAML', { error: toError(error).message }) + + return NextResponse.json( + { error: `Failed to assume role with SAML: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/sts/assume-role-with-web-identity/route.ts b/apps/sim/app/api/tools/sts/assume-role-with-web-identity/route.ts new file mode 100644 index 00000000000..bd951266bad --- /dev/null +++ b/apps/sim/app/api/tools/sts/assume-role-with-web-identity/route.ts @@ -0,0 +1,56 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsStsAssumeRoleWithWebIdentityContract } from '@/lib/api/contracts/tools/aws/sts-assume-role-with-web-identity' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { assumeRoleWithWebIdentity, createUnauthenticatedSTSClient } from '../utils' + +const logger = createLogger('STSAssumeRoleWithWebIdentityAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsStsAssumeRoleWithWebIdentityContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`Assuming role ${params.roleArn} with web identity`) + + const client = createUnauthenticatedSTSClient(params.region) + + try { + const result = await assumeRoleWithWebIdentity( + client, + params.roleArn, + params.roleSessionName, + params.webIdentityToken, + params.providerId, + params.policyArns, + params.policy, + params.durationSeconds + ) + + logger.info('Role assumed successfully with web identity') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to assume role with web identity', { error: toError(error).message }) + + return NextResponse.json( + { error: `Failed to assume role with web identity: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/sts/assume-role/route.ts b/apps/sim/app/api/tools/sts/assume-role/route.ts index 442250b02ea..a66513a96b9 100644 --- a/apps/sim/app/api/tools/sts/assume-role/route.ts +++ b/apps/sim/app/api/tools/sts/assume-role/route.ts @@ -40,7 +40,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { params.policy, params.externalId, params.serialNumber, - params.tokenCode + params.tokenCode, + params.policyArns, + params.tags, + params.transitiveTagKeys ) logger.info('Role assumed successfully') diff --git a/apps/sim/app/api/tools/sts/utils.ts b/apps/sim/app/api/tools/sts/utils.ts index cf4bee12b68..a0caec02260 100644 --- a/apps/sim/app/api/tools/sts/utils.ts +++ b/apps/sim/app/api/tools/sts/utils.ts @@ -1,9 +1,13 @@ import { AssumeRoleCommand, + AssumeRoleWithSAMLCommand, + AssumeRoleWithWebIdentityCommand, GetAccessKeyInfoCommand, GetCallerIdentityCommand, GetSessionTokenCommand, + type PolicyDescriptorType, STSClient, + type Tag, } from '@aws-sdk/client-sts' import type { STSConnectionConfig } from '@/tools/sts/types' @@ -17,6 +21,52 @@ export function createSTSClient(config: STSConnectionConfig): STSClient { }) } +/** + * Creates an STS client for AssumeRoleWithWebIdentity / AssumeRoleWithSAML, + * which authenticate the caller via the supplied token/assertion rather than + * an IAM access key — AWS does not check the request signature for these two + * operations. The SDK's signing middleware still requires a `credentials` + * value to be resolvable, though, so static placeholder credentials are + * supplied explicitly to skip the default credential provider chain (env + * vars, shared config, container/IMDS role). Without this, the client would + * throw a CredentialsProviderError before the request is even sent in + * environments with no ambient AWS identity, even though a real IAM identity + * was never required. + */ +export function createUnauthenticatedSTSClient(region: string): STSClient { + return new STSClient({ + region, + credentials: { accessKeyId: 'anonymous', secretAccessKey: 'anonymous' }, + }) +} + +function parsePolicyArns(policyArns?: string | null): PolicyDescriptorType[] | undefined { + if (!policyArns) return undefined + const arns = policyArns + .split(',') + .map((arn) => arn.trim()) + .filter((arn) => arn.length > 0) + return arns.length > 0 ? arns.map((arn) => ({ arn })) : undefined +} + +function parseTags(tags?: string | null): Tag[] | undefined { + if (!tags) return undefined + const parsed = JSON.parse(tags) as Record + const entries = Object.entries(parsed) + return entries.length > 0 + ? entries.map(([Key, Value]) => ({ Key, Value: String(Value) })) + : undefined +} + +function parseTransitiveTagKeys(transitiveTagKeys?: string | null): string[] | undefined { + if (!transitiveTagKeys) return undefined + const keys = transitiveTagKeys + .split(',') + .map((key) => key.trim()) + .filter((key) => key.length > 0) + return keys.length > 0 ? keys : undefined +} + export async function assumeRole( client: STSClient, roleArn: string, @@ -25,7 +75,10 @@ export async function assumeRole( policy?: string | null, externalId?: string | null, serialNumber?: string | null, - tokenCode?: string | null + tokenCode?: string | null, + policyArns?: string | null, + tags?: string | null, + transitiveTagKeys?: string | null ) { const command = new AssumeRoleCommand({ RoleArn: roleArn, @@ -35,6 +88,93 @@ export async function assumeRole( ...(externalId ? { ExternalId: externalId } : {}), ...(serialNumber ? { SerialNumber: serialNumber } : {}), ...(tokenCode ? { TokenCode: tokenCode } : {}), + ...(() => { + const arns = parsePolicyArns(policyArns) + return arns ? { PolicyArns: arns } : {} + })(), + ...(() => { + const sessionTags = parseTags(tags) + return sessionTags ? { Tags: sessionTags } : {} + })(), + ...(() => { + const keys = parseTransitiveTagKeys(transitiveTagKeys) + return keys ? { TransitiveTagKeys: keys } : {} + })(), + }) + + const response = await client.send(command) + + return { + accessKeyId: response.Credentials?.AccessKeyId ?? '', + secretAccessKey: response.Credentials?.SecretAccessKey ?? '', + sessionToken: response.Credentials?.SessionToken ?? '', + expiration: response.Credentials?.Expiration?.toISOString() ?? null, + assumedRoleArn: response.AssumedRoleUser?.Arn ?? '', + assumedRoleId: response.AssumedRoleUser?.AssumedRoleId ?? '', + packedPolicySize: response.PackedPolicySize ?? null, + sourceIdentity: response.SourceIdentity ?? null, + } +} + +export async function assumeRoleWithWebIdentity( + client: STSClient, + roleArn: string, + roleSessionName: string, + webIdentityToken: string, + providerId?: string | null, + policyArns?: string | null, + policy?: string | null, + durationSeconds?: number | null +) { + const command = new AssumeRoleWithWebIdentityCommand({ + RoleArn: roleArn, + RoleSessionName: roleSessionName, + WebIdentityToken: webIdentityToken, + ...(providerId ? { ProviderId: providerId } : {}), + ...(policy ? { Policy: policy } : {}), + ...(durationSeconds ? { DurationSeconds: durationSeconds } : {}), + ...(() => { + const arns = parsePolicyArns(policyArns) + return arns ? { PolicyArns: arns } : {} + })(), + }) + + const response = await client.send(command) + + return { + accessKeyId: response.Credentials?.AccessKeyId ?? '', + secretAccessKey: response.Credentials?.SecretAccessKey ?? '', + sessionToken: response.Credentials?.SessionToken ?? '', + expiration: response.Credentials?.Expiration?.toISOString() ?? null, + assumedRoleArn: response.AssumedRoleUser?.Arn ?? '', + assumedRoleId: response.AssumedRoleUser?.AssumedRoleId ?? '', + subjectFromWebIdentityToken: response.SubjectFromWebIdentityToken ?? '', + audience: response.Audience ?? null, + provider: response.Provider ?? null, + packedPolicySize: response.PackedPolicySize ?? null, + sourceIdentity: response.SourceIdentity ?? null, + } +} + +export async function assumeRoleWithSAML( + client: STSClient, + roleArn: string, + principalArn: string, + samlAssertion: string, + policyArns?: string | null, + policy?: string | null, + durationSeconds?: number | null +) { + const command = new AssumeRoleWithSAMLCommand({ + RoleArn: roleArn, + PrincipalArn: principalArn, + SAMLAssertion: samlAssertion, + ...(policy ? { Policy: policy } : {}), + ...(durationSeconds ? { DurationSeconds: durationSeconds } : {}), + ...(() => { + const arns = parsePolicyArns(policyArns) + return arns ? { PolicyArns: arns } : {} + })(), }) const response = await client.send(command) @@ -46,6 +186,11 @@ export async function assumeRole( expiration: response.Credentials?.Expiration?.toISOString() ?? null, assumedRoleArn: response.AssumedRoleUser?.Arn ?? '', assumedRoleId: response.AssumedRoleUser?.AssumedRoleId ?? '', + subject: response.Subject ?? null, + subjectType: response.SubjectType ?? null, + issuer: response.Issuer ?? null, + audience: response.Audience ?? null, + nameQualifier: response.NameQualifier ?? null, packedPolicySize: response.PackedPolicySize ?? null, sourceIdentity: response.SourceIdentity ?? null, } diff --git a/apps/sim/app/api/tools/textract/analyze-expense/route.test.ts b/apps/sim/app/api/tools/textract/analyze-expense/route.test.ts new file mode 100644 index 00000000000..76cb459128f --- /dev/null +++ b/apps/sim/app/api/tools/textract/analyze-expense/route.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { normalizeExpenseDocuments } from '@/app/api/tools/textract/analyze-expense/route' + +describe('normalizeExpenseDocuments', () => { + it('maps a documented AWS AnalyzeExpense response shape', () => { + const result = normalizeExpenseDocuments([ + { + ExpenseIndex: 1, + SummaryFields: [ + { + Type: { Text: 'VENDOR_NAME', Confidence: 98.1 }, + ValueDetection: { Text: 'Acme Corp', Confidence: 97.5 }, + LabelDetection: { Text: 'Vendor', Confidence: 90 }, + PageNumber: 1, + Currency: { Code: 'USD', Confidence: 95 }, + GroupProperties: [{ Id: 'g1', Types: ['VENDOR'] }], + }, + ], + LineItemGroups: [ + { + LineItemGroupIndex: 1, + LineItems: [ + { + LineItemExpenseFields: [ + { + Type: { Text: 'ITEM', Confidence: 91 }, + ValueDetection: { Text: 'Widget', Confidence: 93 }, + }, + ], + }, + ], + }, + ], + }, + ]) + + expect(result).toEqual([ + { + expenseIndex: 1, + summaryFields: [ + { + type: { text: 'VENDOR_NAME', confidence: 98.1 }, + valueDetection: { text: 'Acme Corp', confidence: 97.5 }, + labelDetection: { text: 'Vendor', confidence: 90 }, + pageNumber: 1, + currency: { code: 'USD', confidence: 95 }, + groupProperties: [{ id: 'g1', types: ['VENDOR'] }], + }, + ], + lineItemGroups: [ + { + lineItemGroupIndex: 1, + lineItems: [ + { + lineItemExpenseFields: [ + { + type: { text: 'ITEM', confidence: 91 }, + valueDetection: { text: 'Widget', confidence: 93 }, + labelDetection: undefined, + pageNumber: undefined, + currency: undefined, + groupProperties: undefined, + }, + ], + }, + ], + }, + ], + }, + ]) + }) + + it('defaults missing arrays to empty arrays', () => { + expect(normalizeExpenseDocuments([{ ExpenseIndex: 0 }])).toEqual([ + { expenseIndex: 0, summaryFields: [], lineItemGroups: [] }, + ]) + }) +}) diff --git a/apps/sim/app/api/tools/textract/analyze-expense/route.ts b/apps/sim/app/api/tools/textract/analyze-expense/route.ts new file mode 100644 index 00000000000..c9009f89c2f --- /dev/null +++ b/apps/sim/app/api/tools/textract/analyze-expense/route.ts @@ -0,0 +1,218 @@ +import { + AnalyzeExpenseCommand, + type ExpenseDocument, + GetExpenseAnalysisCommand, + StartExpenseAnalysisCommand, + TextractClient, +} from '@aws-sdk/client-textract' +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { textractAnalyzeExpenseContract } from '@/lib/api/contracts/tools/media/document-parse' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + mapTextractSdkError, + parseS3Uri, + pollTextractJob, + resolveDocumentInput, + textractErrorResponse, +} from '@/app/api/tools/textract/shared' + +export const dynamic = 'force-dynamic' +/** Mirrors maxDuration in ../parse/route.ts — see that file's TSDoc for details. */ +export const maxDuration = 5400 + +const logger = createLogger('TextractAnalyzeExpenseAPI') + +/** Response shape shared by AnalyzeExpense and its async Get* counterpart. */ +interface TextractExpenseResult { + JobStatus?: string + StatusMessage?: string + NextToken?: string + ExpenseDocuments?: ExpenseDocument[] + DocumentMetadata?: { Pages?: number } + AnalyzeExpenseModelVersion?: string +} + +export function normalizeExpenseField(field: { + Type?: { Text?: string; Confidence?: number } + ValueDetection?: { Text?: string; Confidence?: number } + LabelDetection?: { Text?: string; Confidence?: number } + PageNumber?: number + Currency?: { Code?: string; Confidence?: number } + GroupProperties?: { Id?: string; Types?: string[] }[] +}) { + return { + type: { text: field.Type?.Text, confidence: field.Type?.Confidence }, + valueDetection: { + text: field.ValueDetection?.Text, + confidence: field.ValueDetection?.Confidence, + }, + labelDetection: field.LabelDetection + ? { text: field.LabelDetection.Text, confidence: field.LabelDetection.Confidence } + : undefined, + pageNumber: field.PageNumber, + currency: field.Currency + ? { code: field.Currency.Code, confidence: field.Currency.Confidence } + : undefined, + groupProperties: field.GroupProperties?.map((group) => ({ + id: group.Id ?? '', + types: group.Types ?? [], + })), + } +} + +export function normalizeExpenseDocuments(documents: ExpenseDocument[]) { + return documents.map((doc) => ({ + expenseIndex: doc.ExpenseIndex, + summaryFields: (doc.SummaryFields ?? []).map(normalizeExpenseField), + lineItemGroups: (doc.LineItemGroups ?? []).map((group) => ({ + lineItemGroupIndex: group.LineItemGroupIndex, + lineItems: (group.LineItems ?? []).map((item) => ({ + lineItemExpenseFields: (item.LineItemExpenseFields ?? []).map(normalizeExpenseField), + })), + })), + })) +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Textract analyze-expense attempt`, { + error: authResult.error || 'Missing userId', + }) + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + const userId = authResult.userId + + const parsed = await parseRequest( + textractAnalyzeExpenseContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + + const validatedData = parsed.data.body + const processingMode = validatedData.processingMode || 'sync' + + logger.info(`[${requestId}] Textract analyze-expense request`, { + processingMode, + hasFile: Boolean(validatedData.file), + hasS3Uri: Boolean(validatedData.s3Uri), + userId, + }) + + const client = new TextractClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + if (processingMode === 'async') { + if (!validatedData.s3Uri) { + return NextResponse.json( + { + success: false, + error: 'S3 URI is required for multi-page processing (s3://bucket/key)', + }, + { status: 400 } + ) + } + + const { bucket, key } = parseS3Uri(validatedData.s3Uri) + logger.info(`[${requestId}] Starting async Textract expense analysis job`, { + s3Bucket: bucket, + s3Key: key, + }) + + const { JobId: jobId } = await client.send( + new StartExpenseAnalysisCommand({ + DocumentLocation: { S3Object: { Bucket: bucket, Name: key } }, + }) + ) + if (!jobId) { + throw new Error('Failed to start Textract expense analysis job: No JobId returned') + } + logger.info(`[${requestId}] Async expense analysis job started`, { jobId }) + + const result = await pollTextractJob( + requestId, + logger, + (nextToken) => + client.send(new GetExpenseAnalysisCommand({ JobId: jobId, NextToken: nextToken })), + (accumulated, page) => ({ + ...accumulated, + ...page, + ExpenseDocuments: [ + ...(accumulated.ExpenseDocuments ?? []), + ...(page.ExpenseDocuments ?? []), + ], + }) + ) + + return NextResponse.json({ + success: true, + output: { + expenseDocuments: normalizeExpenseDocuments(result.ExpenseDocuments ?? []), + documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 }, + modelVersion: result.AnalyzeExpenseModelVersion, + }, + }) + } + + const resolved = await resolveDocumentInput( + { file: validatedData.file, filePath: validatedData.filePath }, + userId, + requestId, + logger + ) + if (!resolved.ok) return resolved.response + const { bytes, isPdf } = resolved.document + + let result: TextractExpenseResult + try { + result = await client.send(new AnalyzeExpenseCommand({ Document: { Bytes: bytes } })) + } catch (error) { + throw mapTextractSdkError(error, isPdf) + } + + logger.info(`[${requestId}] Textract analyze-expense successful`, { + pageCount: result.DocumentMetadata?.Pages ?? 0, + expenseDocumentCount: result.ExpenseDocuments?.length ?? 0, + }) + + return NextResponse.json({ + success: true, + output: { + expenseDocuments: normalizeExpenseDocuments(result.ExpenseDocuments ?? []), + documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 }, + }, + }) + } catch (error) { + return textractErrorResponse(error, requestId, logger) + } +}) diff --git a/apps/sim/app/api/tools/textract/analyze-id/route.test.ts b/apps/sim/app/api/tools/textract/analyze-id/route.test.ts new file mode 100644 index 00000000000..5798f2441fd --- /dev/null +++ b/apps/sim/app/api/tools/textract/analyze-id/route.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { normalizeIdentityDocuments } from '@/app/api/tools/textract/analyze-id/route' + +describe('normalizeIdentityDocuments', () => { + it('maps a documented AWS AnalyzeID response shape', () => { + const result = normalizeIdentityDocuments([ + { + DocumentIndex: 1, + IdentityDocumentFields: [ + { + Type: { Text: 'FIRST_NAME', Confidence: 99 }, + ValueDetection: { Text: 'Jane', Confidence: 98 }, + }, + { + Type: { + Text: 'DATE_OF_BIRTH', + Confidence: 97, + NormalizedValue: { Value: '1990-01-01', ValueType: 'Date' }, + }, + ValueDetection: { + Text: '01/01/1990', + Confidence: 96, + NormalizedValue: { Value: '1990-01-01T00:00:00', ValueType: 'Date' }, + }, + }, + ], + }, + ]) + + expect(result).toEqual([ + { + documentIndex: 1, + identityDocumentFields: [ + { + type: { text: 'FIRST_NAME', confidence: 99, normalizedValue: undefined }, + valueDetection: { text: 'Jane', confidence: 98, normalizedValue: undefined }, + }, + { + type: { + text: 'DATE_OF_BIRTH', + confidence: 97, + normalizedValue: { value: '1990-01-01', valueType: 'Date' }, + }, + valueDetection: { + text: '01/01/1990', + confidence: 96, + normalizedValue: { value: '1990-01-01T00:00:00', valueType: 'Date' }, + }, + }, + ], + }, + ]) + }) + + it('defaults missing fields to an empty array', () => { + expect(normalizeIdentityDocuments([{ DocumentIndex: 0 }])).toEqual([ + { documentIndex: 0, identityDocumentFields: [] }, + ]) + }) +}) diff --git a/apps/sim/app/api/tools/textract/analyze-id/route.ts b/apps/sim/app/api/tools/textract/analyze-id/route.ts new file mode 100644 index 00000000000..9edf2ce97ed --- /dev/null +++ b/apps/sim/app/api/tools/textract/analyze-id/route.ts @@ -0,0 +1,150 @@ +import { AnalyzeIDCommand, type IdentityDocument, TextractClient } from '@aws-sdk/client-textract' +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { textractAnalyzeIdContract } from '@/lib/api/contracts/tools/media/document-parse' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + mapTextractSdkError, + resolveDocumentInput, + textractErrorResponse, +} from '@/app/api/tools/textract/shared' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('TextractAnalyzeIdAPI') + +export function normalizeIdentityDocuments(documents: IdentityDocument[]) { + return documents.map((doc) => ({ + documentIndex: doc.DocumentIndex, + identityDocumentFields: (doc.IdentityDocumentFields ?? []).map((field) => ({ + type: { + text: field.Type?.Text, + confidence: field.Type?.Confidence, + normalizedValue: field.Type?.NormalizedValue + ? { + value: field.Type.NormalizedValue.Value, + valueType: field.Type.NormalizedValue.ValueType, + } + : undefined, + }, + valueDetection: { + text: field.ValueDetection?.Text, + confidence: field.ValueDetection?.Confidence, + normalizedValue: field.ValueDetection?.NormalizedValue + ? { + value: field.ValueDetection.NormalizedValue.Value, + valueType: field.ValueDetection.NormalizedValue.ValueType, + } + : undefined, + }, + })), + })) +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Textract analyze-id attempt`, { + error: authResult.error || 'Missing userId', + }) + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + const userId = authResult.userId + + const parsed = await parseRequest( + textractAnalyzeIdContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + + const validatedData = parsed.data.body + + logger.info(`[${requestId}] Textract analyze-id request`, { + hasFile: Boolean(validatedData.file), + hasBackFile: Boolean(validatedData.fileBack || validatedData.filePathBack), + userId, + }) + + const front = await resolveDocumentInput( + { file: validatedData.file, filePath: validatedData.filePath }, + userId, + requestId, + logger + ) + if (!front.ok) return front.response + + const documentPages = [{ Bytes: front.document.bytes }] + let isPdf = front.document.isPdf + + if (validatedData.fileBack || validatedData.filePathBack) { + const back = await resolveDocumentInput( + { file: validatedData.fileBack, filePath: validatedData.filePathBack }, + userId, + requestId, + logger + ) + if (!back.ok) return back.response + documentPages.push({ Bytes: back.document.bytes }) + isPdf = isPdf || back.document.isPdf + } + + const client = new TextractClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + let result: { + AnalyzeIDModelVersion?: string + DocumentMetadata?: { Pages?: number } + IdentityDocuments?: IdentityDocument[] + } + try { + result = await client.send(new AnalyzeIDCommand({ DocumentPages: documentPages })) + } catch (error) { + throw mapTextractSdkError(error, isPdf, { hasAsyncMode: false }) + } + + logger.info(`[${requestId}] Textract analyze-id successful`, { + pageCount: result.DocumentMetadata?.Pages ?? 0, + documentCount: result.IdentityDocuments?.length ?? 0, + }) + + return NextResponse.json({ + success: true, + output: { + identityDocuments: normalizeIdentityDocuments(result.IdentityDocuments ?? []), + documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 }, + modelVersion: result.AnalyzeIDModelVersion, + }, + }) + } catch (error) { + return textractErrorResponse(error, requestId, logger) + } +}) diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index 82eb65ff830..ec3a6d8e35f 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -1,26 +1,28 @@ -import crypto from 'crypto' +import { + AnalyzeDocumentCommand, + DetectDocumentTextCommand, + type FeatureType, + GetDocumentAnalysisCommand, + GetDocumentTextDetectionCommand, + StartDocumentAnalysisCommand, + StartDocumentTextDetectionCommand, + TextractClient, +} from '@aws-sdk/client-textract' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { sleep } from '@sim/utils/helpers' -import { type NextRequest, NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { textractParseContract } from '@/lib/api/contracts/tools/media/document-parse' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' -import { validateS3BucketName } from '@/lib/core/security/input-validation' -import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { - downloadServableFileFromStorage, - resolveInternalFileUrl, -} from '@/lib/uploads/utils/file-utils.server' -import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' -import { assertToolFileAccess } from '@/app/api/files/authorization' + mapTextractSdkError, + parseS3Uri, + pollTextractJob, + resolveDocumentInput, + textractErrorResponse, +} from '@/app/api/tools/textract/shared' export const dynamic = 'force-dynamic' /** @@ -32,244 +34,15 @@ export const maxDuration = 5400 const logger = createLogger('TextractParseAPI') -function getSignatureKey( - key: string, - dateStamp: string, - regionName: string, - serviceName: string -): Buffer { - const kDate = crypto.createHmac('sha256', `AWS4${key}`).update(dateStamp).digest() - const kRegion = crypto.createHmac('sha256', kDate).update(regionName).digest() - const kService = crypto.createHmac('sha256', kRegion).update(serviceName).digest() - const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest() - return kSigning -} - -function signAwsRequest( - method: string, - host: string, - uri: string, - body: string, - accessKeyId: string, - secretAccessKey: string, - region: string, - service: string, - amzTarget: string -): Record { - const date = new Date() - const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, '') - const dateStamp = amzDate.slice(0, 8) - - const payloadHash = crypto.createHash('sha256').update(body).digest('hex') - - const canonicalHeaders = - `content-type:application/x-amz-json-1.1\n` + - `host:${host}\n` + - `x-amz-date:${amzDate}\n` + - `x-amz-target:${amzTarget}\n` - - const signedHeaders = 'content-type;host;x-amz-date;x-amz-target' - - const canonicalRequest = `${method}\n${uri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}` - - const algorithm = 'AWS4-HMAC-SHA256' - const credentialScope = `${dateStamp}/${region}/${service}/aws4_request` - const stringToSign = `${algorithm}\n${amzDate}\n${credentialScope}\n${crypto.createHash('sha256').update(canonicalRequest).digest('hex')}` - - const signingKey = getSignatureKey(secretAccessKey, dateStamp, region, service) - const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex') - - const authorizationHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` - - return { - 'Content-Type': 'application/x-amz-json-1.1', - Host: host, - 'X-Amz-Date': amzDate, - 'X-Amz-Target': amzTarget, - Authorization: authorizationHeader, - } -} - -async function fetchDocumentBytes(url: string): Promise<{ bytes: string; contentType: string }> { - const urlValidation = await validateUrlWithDNS(url, 'Document URL') - if (!urlValidation.isValid) { - throw new Error(urlValidation.error || 'Invalid document URL') - } - - const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { - method: 'GET', - }) - if (!response.ok) { - await response.text().catch(() => {}) - throw new Error(`Failed to fetch document: ${response.statusText}`) - } - - const arrayBuffer = await response.arrayBuffer() - const bytes = Buffer.from(arrayBuffer).toString('base64') - const contentType = response.headers.get('content-type') || 'application/octet-stream' - - return { bytes, contentType } -} - -function parseS3Uri(s3Uri: string): { bucket: string; key: string } { - const match = s3Uri.match(/^s3:\/\/([^/]+)\/(.+)$/) - if (!match) { - throw new Error( - `Invalid S3 URI format: ${s3Uri}. Expected format: s3://bucket-name/path/to/object` - ) - } - - const bucket = match[1] - const key = match[2] - - const bucketValidation = validateS3BucketName(bucket, 'S3 bucket name') - if (!bucketValidation.isValid) { - throw new Error(bucketValidation.error) - } - - if (key.includes('..') || key.startsWith('/')) { - throw new Error('S3 key contains invalid path traversal sequences') - } - - return { bucket, key } -} - -async function callTextractAsync( - host: string, - amzTarget: string, - body: Record, - accessKeyId: string, - secretAccessKey: string, - region: string -): Promise> { - const bodyString = JSON.stringify(body) - const headers = signAwsRequest( - 'POST', - host, - '/', - bodyString, - accessKeyId, - secretAccessKey, - region, - 'textract', - amzTarget - ) - - const response = await fetch(`https://${host}/`, { - method: 'POST', - headers, - body: bodyString, - }) - - if (!response.ok) { - const errorText = await response.text() - let errorMessage = `Textract API error: ${response.statusText}` - try { - const errorJson = JSON.parse(errorText) - if (errorJson.Message) { - errorMessage = errorJson.Message - } else if (errorJson.__type) { - errorMessage = `${errorJson.__type}: ${errorJson.message || errorText}` - } - } catch { - // Use default error message - } - throw new Error(errorMessage) - } - - return response.json() -} - -async function pollForJobCompletion( - host: string, - jobId: string, - accessKeyId: string, - secretAccessKey: string, - region: string, - useAnalyzeDocument: boolean, - requestId: string -): Promise> { - const pollIntervalMs = 5000 - const maxPollTimeMs = getMaxExecutionTimeout() - const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs) - - const getTarget = useAnalyzeDocument - ? 'Textract.GetDocumentAnalysis' - : 'Textract.GetDocumentTextDetection' - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const result = await callTextractAsync( - host, - getTarget, - { JobId: jobId }, - accessKeyId, - secretAccessKey, - region - ) - - const jobStatus = result.JobStatus as string - - if (jobStatus === 'SUCCEEDED') { - logger.info(`[${requestId}] Async job completed successfully after ${attempt + 1} polls`) - - let allBlocks = (result.Blocks as unknown[]) || [] - let nextToken = result.NextToken as string | undefined - - while (nextToken) { - const nextResult = await callTextractAsync( - host, - getTarget, - { JobId: jobId, NextToken: nextToken }, - accessKeyId, - secretAccessKey, - region - ) - allBlocks = allBlocks.concat((nextResult.Blocks as unknown[]) || []) - nextToken = nextResult.NextToken as string | undefined - } - - return { - ...result, - Blocks: allBlocks, - } - } - - if (jobStatus === 'FAILED') { - throw new Error(`Textract job failed: ${result.StatusMessage || 'Unknown error'}`) - } - - if (jobStatus === 'PARTIAL_SUCCESS') { - logger.warn(`[${requestId}] Job completed with partial success: ${result.StatusMessage}`) - - let allBlocks = (result.Blocks as unknown[]) || [] - let nextToken = result.NextToken as string | undefined - - while (nextToken) { - const nextResult = await callTextractAsync( - host, - getTarget, - { JobId: jobId, NextToken: nextToken }, - accessKeyId, - secretAccessKey, - region - ) - allBlocks = allBlocks.concat((nextResult.Blocks as unknown[]) || []) - nextToken = nextResult.NextToken as string | undefined - } - - return { - ...result, - Blocks: allBlocks, - } - } - - logger.info(`[${requestId}] Job status: ${jobStatus}, attempt ${attempt + 1}/${maxAttempts}`) - await sleep(pollIntervalMs) - } - - throw new Error( - `Timeout waiting for Textract job to complete (max ${maxPollTimeMs / 1000} seconds)` - ) +/** Response shape shared by AnalyzeDocument/DetectDocumentText and their async Get* counterparts. */ +interface TextractDocumentResult { + JobStatus?: string + StatusMessage?: string + NextToken?: string + Blocks?: unknown[] + DocumentMetadata?: { Pages?: number } + AnalyzeDocumentModelVersion?: string + DetectDocumentTextModelVersion?: string } export const POST = withRouteHandler(async (request: NextRequest) => { @@ -277,20 +50,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Textract parse attempt`, { error: authResult.error || 'Missing userId', }) return NextResponse.json( - { - success: false, - error: authResult.error || 'Unauthorized', - }, + { success: false, error: authResult.error || 'Unauthorized' }, { status: 401 } ) } - const userId = authResult.userId const parsed = await parseRequest( @@ -314,11 +82,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const validatedData = parsed.data.body - const processingMode = validatedData.processingMode || 'sync' - const featureTypes = validatedData.featureTypes ?? [] + const featureTypes = (validatedData.featureTypes ?? []) as FeatureType[] const useAnalyzeDocument = featureTypes.length > 0 - const host = `textract.${validatedData.region}.amazonaws.com` + const queriesConfig = + validatedData.queries && validatedData.queries.length > 0 && featureTypes.includes('QUERIES') + ? { + Queries: validatedData.queries.map((q) => ({ + Text: q.Text, + Alias: q.Alias, + Pages: q.Pages, + })), + } + : undefined logger.info(`[${requestId}] Textract parse request`, { processingMode, @@ -328,6 +104,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId, }) + const client = new TextractClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + if (processingMode === 'async') { if (!validatedData.s3Uri) { return NextResponse.json( @@ -339,299 +123,98 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const { bucket: s3Bucket, key: s3Key } = parseS3Uri(validatedData.s3Uri) - - logger.info(`[${requestId}] Starting async Textract job`, { s3Bucket, s3Key }) - - const startTarget = useAnalyzeDocument - ? 'Textract.StartDocumentAnalysis' - : 'Textract.StartDocumentTextDetection' - - const startBody: Record = { - DocumentLocation: { - S3Object: { - Bucket: s3Bucket, - Name: s3Key, - }, - }, - } - - if (useAnalyzeDocument) { - startBody.FeatureTypes = featureTypes + const { bucket, key } = parseS3Uri(validatedData.s3Uri) + logger.info(`[${requestId}] Starting async Textract job`, { s3Bucket: bucket, s3Key: key }) - if ( - validatedData.queries && - validatedData.queries.length > 0 && - featureTypes.includes('QUERIES') - ) { - startBody.QueriesConfig = { - Queries: validatedData.queries.map((q) => ({ - Text: q.Text, - Alias: q.Alias, - Pages: q.Pages, - })), - } - } - } - - const startResult = await callTextractAsync( - host, - startTarget, - startBody, - validatedData.accessKeyId, - validatedData.secretAccessKey, - validatedData.region - ) - - const jobId = startResult.JobId as string + const { JobId: jobId } = useAnalyzeDocument + ? await client.send( + new StartDocumentAnalysisCommand({ + DocumentLocation: { S3Object: { Bucket: bucket, Name: key } }, + FeatureTypes: featureTypes, + QueriesConfig: queriesConfig, + }) + ) + : await client.send( + new StartDocumentTextDetectionCommand({ + DocumentLocation: { S3Object: { Bucket: bucket, Name: key } }, + }) + ) if (!jobId) { throw new Error('Failed to start Textract job: No JobId returned') } - logger.info(`[${requestId}] Async job started`, { jobId }) - const textractData = await pollForJobCompletion( - host, - jobId, - validatedData.accessKeyId, - validatedData.secretAccessKey, - validatedData.region, - useAnalyzeDocument, - requestId + const result = await pollTextractJob( + requestId, + logger, + async (nextToken) => + useAnalyzeDocument + ? await client.send( + new GetDocumentAnalysisCommand({ JobId: jobId, NextToken: nextToken }) + ) + : await client.send( + new GetDocumentTextDetectionCommand({ JobId: jobId, NextToken: nextToken }) + ), + (accumulated, page) => ({ + ...accumulated, + ...page, + Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])], + }) ) logger.info(`[${requestId}] Textract async parse successful`, { - pageCount: (textractData.DocumentMetadata as { Pages?: number })?.Pages ?? 0, - blockCount: (textractData.Blocks as unknown[])?.length ?? 0, + pageCount: result.DocumentMetadata?.Pages ?? 0, + blockCount: result.Blocks?.length ?? 0, }) return NextResponse.json({ success: true, output: { - blocks: textractData.Blocks ?? [], - documentMetadata: { - pages: (textractData.DocumentMetadata as { Pages?: number })?.Pages ?? 0, - }, - modelVersion: (textractData.AnalyzeDocumentModelVersion ?? - textractData.DetectDocumentTextModelVersion) as string | undefined, + blocks: result.Blocks ?? [], + documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 }, + modelVersion: result.AnalyzeDocumentModelVersion ?? result.DetectDocumentTextModelVersion, }, }) } - let bytes = '' - let contentType = 'application/octet-stream' - let isPdf = false - - if (validatedData.file) { - let userFile - try { - userFile = processSingleFileToUserFile(validatedData.file, requestId, logger) - } catch (error) { - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to process file'), - }, - { status: 400 } - ) - } - - const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) - if (denied) return denied - const { buffer, contentType: resolvedContentType } = await downloadServableFileFromStorage( - userFile, - requestId, - logger - ) - bytes = buffer.toString('base64') - contentType = resolvedContentType || userFile.type || 'application/octet-stream' - isPdf = contentType.includes('pdf') || userFile.name?.toLowerCase().endsWith('.pdf') - } else if (validatedData.filePath) { - let fileUrl = validatedData.filePath - - const isInternalFilePath = isInternalFileUrl(fileUrl) - - if (isInternalFilePath) { - const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) - if (resolution.error) { - return NextResponse.json( - { - success: false, - error: resolution.error.message, - }, - { status: resolution.error.status } - ) - } - fileUrl = resolution.fileUrl || fileUrl - } else if (fileUrl.startsWith('/')) { - logger.warn(`[${requestId}] Invalid internal path`, { - userId, - path: fileUrl.substring(0, 50), - }) - return NextResponse.json( - { - success: false, - error: 'Invalid file path. Only uploaded files are supported for internal paths.', - }, - { status: 400 } - ) - } else { - const urlValidation = await validateUrlWithDNS(fileUrl, 'Document URL') - if (!urlValidation.isValid) { - logger.warn(`[${requestId}] SSRF attempt blocked`, { - userId, - url: fileUrl.substring(0, 100), - error: urlValidation.error, - }) - return NextResponse.json( - { - success: false, - error: urlValidation.error, - }, - { status: 400 } - ) - } - } - - const fetched = await fetchDocumentBytes(fileUrl) - bytes = fetched.bytes - contentType = fetched.contentType - isPdf = contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf') - } else { - return NextResponse.json( - { - success: false, - error: 'File input is required for single-page processing', - }, - { status: 400 } - ) - } - - const uri = '/' - - let textractBody: Record - let amzTarget: string - - if (useAnalyzeDocument) { - amzTarget = 'Textract.AnalyzeDocument' - textractBody = { - Document: { - Bytes: bytes, - }, - FeatureTypes: featureTypes, - } - - if ( - validatedData.queries && - validatedData.queries.length > 0 && - featureTypes.includes('QUERIES') - ) { - textractBody.QueriesConfig = { - Queries: validatedData.queries.map((q) => ({ - Text: q.Text, - Alias: q.Alias, - Pages: q.Pages, - })), - } - } - } else { - amzTarget = 'Textract.DetectDocumentText' - textractBody = { - Document: { - Bytes: bytes, - }, - } - } - - const bodyString = JSON.stringify(textractBody) - - const headers = signAwsRequest( - 'POST', - host, - uri, - bodyString, - validatedData.accessKeyId, - validatedData.secretAccessKey, - validatedData.region, - 'textract', - amzTarget + const resolved = await resolveDocumentInput( + { file: validatedData.file, filePath: validatedData.filePath }, + userId, + requestId, + logger ) + if (!resolved.ok) return resolved.response + const { bytes, isPdf } = resolved.document - const textractResponse = await fetch(`https://${host}${uri}`, { - method: 'POST', - headers, - body: bodyString, - }) - - if (!textractResponse.ok) { - const errorText = await textractResponse.text() - logger.error(`[${requestId}] Textract API error:`, errorText) - - let errorMessage = `Textract API error: ${textractResponse.statusText}` - let isUnsupportedFormat = false - try { - const errorJson = JSON.parse(errorText) - if (errorJson.Message) { - errorMessage = errorJson.Message - } else if (errorJson.__type) { - errorMessage = `${errorJson.__type}: ${errorJson.message || errorText}` - } - // Check for unsupported document format error - isUnsupportedFormat = - errorJson.__type === 'UnsupportedDocumentException' || - errorJson.Message?.toLowerCase().includes('unsupported document') || - errorText.toLowerCase().includes('unsupported document') - } catch { - isUnsupportedFormat = errorText.toLowerCase().includes('unsupported document') - } - - // Provide helpful message for unsupported format (likely multi-page PDF) - if (isUnsupportedFormat && isPdf) { - errorMessage = - 'This document format is not supported in Single Page mode. If this is a multi-page PDF, please use "Multi-Page (PDF, TIFF via S3)" mode instead, which requires uploading your document to S3 first. Single Page mode only supports JPEG, PNG, and single-page PDF files.' - } - - return NextResponse.json( - { - success: false, - error: errorMessage, - }, - { status: textractResponse.status } - ) + let result: TextractDocumentResult + try { + result = useAnalyzeDocument + ? await client.send( + new AnalyzeDocumentCommand({ + Document: { Bytes: bytes }, + FeatureTypes: featureTypes, + QueriesConfig: queriesConfig, + }) + ) + : await client.send(new DetectDocumentTextCommand({ Document: { Bytes: bytes } })) + } catch (error) { + throw mapTextractSdkError(error, isPdf) } - const textractData = await textractResponse.json() - logger.info(`[${requestId}] Textract parse successful`, { - pageCount: textractData.DocumentMetadata?.Pages ?? 0, - blockCount: textractData.Blocks?.length ?? 0, + pageCount: result.DocumentMetadata?.Pages ?? 0, + blockCount: result.Blocks?.length ?? 0, }) return NextResponse.json({ success: true, output: { - blocks: textractData.Blocks ?? [], - documentMetadata: { - pages: textractData.DocumentMetadata?.Pages ?? 0, - }, - modelVersion: - textractData.AnalyzeDocumentModelVersion ?? - textractData.DetectDocumentTextModelVersion ?? - undefined, + blocks: result.Blocks ?? [], + documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 }, + modelVersion: result.AnalyzeDocumentModelVersion ?? result.DetectDocumentTextModelVersion, }, }) } catch (error) { - const notReady = docNotReadyResponse(error) - if (notReady) return notReady - - logger.error(`[${requestId}] Error in Textract parse:`, error) - - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Internal server error'), - }, - { status: 500 } - ) + return textractErrorResponse(error, requestId, logger) } }) diff --git a/apps/sim/app/api/tools/textract/shared.test.ts b/apps/sim/app/api/tools/textract/shared.test.ts new file mode 100644 index 00000000000..b95b6753453 --- /dev/null +++ b/apps/sim/app/api/tools/textract/shared.test.ts @@ -0,0 +1,165 @@ +/** + * @vitest-environment node + */ +import { createLogger } from '@sim/logger' +import { describe, expect, it } from 'vitest' +import { + mapTextractSdkError, + parseS3Uri, + pollTextractJob, + TextractRouteError, +} from '@/app/api/tools/textract/shared' + +const logger = createLogger('TextractSharedTest') + +describe('parseS3Uri', () => { + it('parses a valid s3 URI', () => { + expect(parseS3Uri('s3://my-bucket/path/to/doc.pdf')).toEqual({ + bucket: 'my-bucket', + key: 'path/to/doc.pdf', + }) + }) + + it('rejects a malformed URI', () => { + expect(() => parseS3Uri('not-an-s3-uri')).toThrow(TextractRouteError) + }) + + it('rejects path traversal in the key', () => { + expect(() => parseS3Uri('s3://my-bucket/../secrets.pdf')).toThrow('path traversal') + }) +}) + +describe('mapTextractSdkError', () => { + it('gives a friendly hint for unsupported PDFs in single-page mode', () => { + const mapped = mapTextractSdkError( + { name: 'UnsupportedDocumentException', message: 'Unsupported document' }, + true + ) + expect(mapped.status).toBe(400) + expect(mapped.message).toContain('Multi-Page (PDF, TIFF via S3)') + }) + + it('omits the multi-page hint for operations without an async mode', () => { + const mapped = mapTextractSdkError( + { name: 'UnsupportedDocumentException', message: 'Unsupported document' }, + true, + { hasAsyncMode: false } + ) + expect(mapped.message).not.toContain('Multi-Page') + expect(mapped.message).toContain('Only JPEG, PNG, and single-page PDF files are supported') + }) + + it('does not rewrite the message for non-PDF unsupported documents', () => { + const mapped = mapTextractSdkError( + { name: 'UnsupportedDocumentException', message: 'Unsupported document' }, + false + ) + expect(mapped.message).toBe('Unsupported document') + }) + + it('uses the SDK http status when under 500', () => { + const mapped = mapTextractSdkError( + { + name: 'InvalidParameterException', + message: 'Bad param', + $metadata: { httpStatusCode: 400 }, + }, + false + ) + expect(mapped.status).toBe(400) + expect(mapped.message).toBe('Bad param') + }) + + it('passes through a 5xx SDK status so tool-execution retry logic still fires', () => { + const mapped = mapTextractSdkError( + { message: 'Internal failure', $metadata: { httpStatusCode: 500 } }, + false + ) + expect(mapped.status).toBe(500) + }) + + it('defaults to 500 when the SDK gives no http status, since that implies a server-side failure', () => { + const mapped = mapTextractSdkError({ message: 'Unknown failure' }, false) + expect(mapped.status).toBe(500) + }) +}) + +describe('pollTextractJob', () => { + it('returns immediately on SUCCEEDED with no NextToken', async () => { + const result = await pollTextractJob( + 'req-1', + logger, + async () => ({ JobStatus: 'SUCCEEDED', Blocks: [{ Id: '1' }] }), + (accumulated, page) => ({ + ...page, + Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])], + }) + ) + + expect(result.JobStatus).toBe('SUCCEEDED') + expect(result.Blocks).toHaveLength(1) + }) + + it('follows NextToken pagination and merges pages', async () => { + let calls = 0 + const result = await pollTextractJob( + 'req-2', + logger, + async (nextToken) => { + calls += 1 + if (!nextToken) return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '1' }], NextToken: 'next' } + return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '2' }] } + }, + (accumulated, page) => ({ + ...accumulated, + ...page, + Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])], + }) + ) + + expect(calls).toBe(2) + expect(result.Blocks).toHaveLength(2) + }) + + it('preserves fields the first page has but a later page omits (e.g. DocumentMetadata)', async () => { + const result = await pollTextractJob<{ + JobStatus?: string + NextToken?: string + Blocks?: unknown[] + DocumentMetadata?: { Pages?: number } + }>( + 'req-4', + logger, + async (nextToken) => { + if (!nextToken) { + return { + JobStatus: 'SUCCEEDED', + Blocks: [{ Id: '1' }], + DocumentMetadata: { Pages: 3 }, + NextToken: 'next', + } + } + return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '2' }] } + }, + (accumulated, page) => ({ + ...accumulated, + ...page, + Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])], + }) + ) + + expect(result.Blocks).toHaveLength(2) + expect(result.DocumentMetadata).toEqual({ Pages: 3 }) + }) + + it('throws a TextractRouteError when the job fails', async () => { + await expect( + pollTextractJob( + 'req-3', + logger, + async () => ({ JobStatus: 'FAILED', StatusMessage: 'boom' }), + (accumulated) => accumulated + ) + ).rejects.toThrow('Textract job failed: boom') + }) +}) diff --git a/apps/sim/app/api/tools/textract/shared.ts b/apps/sim/app/api/tools/textract/shared.ts new file mode 100644 index 00000000000..90297f21498 --- /dev/null +++ b/apps/sim/app/api/tools/textract/shared.ts @@ -0,0 +1,305 @@ +import type { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { NextResponse } from 'next/server' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { validateS3BucketName } from '@/lib/core/security/input-validation' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import type { RawFileInput } from '@/lib/uploads/utils/file-utils' +import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' +import { + downloadServableFileFromStorage, + resolveInternalFileUrl, +} from '@/lib/uploads/utils/file-utils.server' +import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' +import { assertToolFileAccess } from '@/app/api/files/authorization' + +type RouteLogger = ReturnType + +/** Thrown by AWS SDK call sites so route handlers can map failures to the right HTTP status. */ +export class TextractRouteError extends Error { + status: number + + constructor(message: string, status = 500) { + super(message) + this.name = 'TextractRouteError' + this.status = status + } +} + +export function textractErrorResponse( + error: unknown, + requestId: string, + logger: RouteLogger +): NextResponse { + const notReady = docNotReadyResponse(error) + if (notReady) return notReady + + logger.error(`[${requestId}] Error in Textract request:`, error) + const status = error instanceof TextractRouteError ? error.status : 500 + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Internal server error') }, + { status } + ) +} + +/** + * Maps an AWS SDK TextractClient rejection to a client-facing error, with a friendly hint for the + * common "PDF used in single-page mode" mistake. The real AWS HTTP status (including 5xx) is + * passed through so the tool-execution layer's retry logic can still treat throttling/internal + * errors as retryable, matching the pre-migration hand-rolled-signing behavior. + */ +export function mapTextractSdkError( + error: unknown, + isPdf: boolean, + options?: { hasAsyncMode?: boolean } +): TextractRouteError { + const err = error as { + name?: string + message?: string + $metadata?: { httpStatusCode?: number } + } + const hasAsyncMode = options?.hasAsyncMode ?? true + + const isUnsupportedFormat = + err.name === 'UnsupportedDocumentException' || + Boolean(err.message?.toLowerCase().includes('unsupported document')) + + if (isUnsupportedFormat && isPdf) { + const hint = hasAsyncMode + ? ' If this is a multi-page PDF, please use "Multi-Page (PDF, TIFF via S3)" mode instead, which requires uploading your document to S3 first. Single Page mode only supports JPEG, PNG, and single-page PDF files.' + : ' Only JPEG, PNG, and single-page PDF files are supported.' + return new TextractRouteError(`This document format is not supported.${hint}`, 400) + } + + const status = err.$metadata?.httpStatusCode ?? 500 + return new TextractRouteError(err.message || 'Textract API error', status) +} + +export interface ResolvedDocument { + bytes: Buffer + contentType: string + isPdf: boolean +} + +export type ResolveDocumentResult = + | { ok: true; document: ResolvedDocument } + | { ok: false; response: NextResponse } + +/** Passes through the document host's real HTTP status on failure, so tool-execution retry logic can still treat a transient 5xx as retryable. */ +async function fetchDocumentBytes(url: string): Promise<{ bytes: Buffer; contentType: string }> { + const urlValidation = await validateUrlWithDNS(url, 'Document URL') + if (!urlValidation.isValid) { + throw new TextractRouteError(urlValidation.error || 'Invalid document URL', 400) + } + + const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { + method: 'GET', + }) + if (!response.ok) { + await response.text().catch(() => {}) + throw new TextractRouteError( + `Failed to fetch document: ${response.statusText}`, + response.status + ) + } + + const arrayBuffer = await response.arrayBuffer() + const contentType = response.headers.get('content-type') || 'application/octet-stream' + + return { bytes: Buffer.from(arrayBuffer), contentType } +} + +/** Resolves a document input (uploaded file reference or URL) to raw bytes for the Textract Document.Bytes field. */ +export async function resolveDocumentInput( + input: { file?: RawFileInput; filePath?: string }, + userId: string, + requestId: string, + logger: RouteLogger +): Promise { + if (input.file) { + let userFile: ReturnType + try { + userFile = processSingleFileToUserFile(input.file, requestId, logger) + } catch (error) { + return { + ok: false, + response: NextResponse.json( + { success: false, error: getErrorMessage(error, 'Failed to process file') }, + { status: 400 } + ), + } + } + + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return { ok: false, response: denied } + + const { buffer, contentType } = await downloadServableFileFromStorage( + userFile, + requestId, + logger + ) + const resolvedContentType = contentType || userFile.type || 'application/octet-stream' + + return { + ok: true, + document: { + bytes: buffer, + contentType: resolvedContentType, + isPdf: + resolvedContentType.includes('pdf') || + Boolean(userFile.name?.toLowerCase().endsWith('.pdf')), + }, + } + } + + if (input.filePath) { + let fileUrl = input.filePath + const isInternalFilePath = isInternalFileUrl(fileUrl) + + if (isInternalFilePath) { + const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) + if (resolution.error) { + return { + ok: false, + response: NextResponse.json( + { success: false, error: resolution.error.message }, + { status: resolution.error.status } + ), + } + } + fileUrl = resolution.fileUrl || fileUrl + } else if (fileUrl.startsWith('/')) { + logger.warn(`[${requestId}] Invalid internal path`, { + userId, + path: fileUrl.substring(0, 50), + }) + return { + ok: false, + response: NextResponse.json( + { + success: false, + error: 'Invalid file path. Only uploaded files are supported for internal paths.', + }, + { status: 400 } + ), + } + } else { + const urlValidation = await validateUrlWithDNS(fileUrl, 'Document URL') + if (!urlValidation.isValid) { + logger.warn(`[${requestId}] SSRF attempt blocked`, { + userId, + url: fileUrl.substring(0, 100), + error: urlValidation.error, + }) + return { + ok: false, + response: NextResponse.json( + { success: false, error: urlValidation.error }, + { status: 400 } + ), + } + } + } + + const fetched = await fetchDocumentBytes(fileUrl) + return { + ok: true, + document: { + bytes: fetched.bytes, + contentType: fetched.contentType, + isPdf: fetched.contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf'), + }, + } + } + + return { + ok: false, + response: NextResponse.json( + { success: false, error: 'Document input is required' }, + { status: 400 } + ), + } +} + +export function parseS3Uri(s3Uri: string): { bucket: string; key: string } { + const match = s3Uri.match(/^s3:\/\/([^/]+)\/(.+)$/) + if (!match) { + throw new TextractRouteError( + `Invalid S3 URI format: ${s3Uri}. Expected format: s3://bucket-name/path/to/object`, + 400 + ) + } + + const bucket = match[1] + const key = match[2] + + const bucketValidation = validateS3BucketName(bucket, 'S3 bucket name') + if (!bucketValidation.isValid) { + throw new TextractRouteError(bucketValidation.error || 'Invalid S3 bucket name', 400) + } + + if (key.includes('..') || key.startsWith('/')) { + throw new TextractRouteError('S3 key contains invalid path traversal sequences', 400) + } + + return { bucket, key } +} + +interface PollableJobResult { + JobStatus?: string + StatusMessage?: string + NextToken?: string +} + +/** Polls a started async Textract job (StartDocumentAnalysis/StartDocumentTextDetection/StartExpenseAnalysis) until it completes, following NextToken pagination on success. */ +export async function pollTextractJob( + requestId: string, + logger: RouteLogger, + getPage: (nextToken?: string) => Promise, + mergePage: (accumulated: TResult, page: TResult) => TResult +): Promise { + const pollIntervalMs = 5000 + const maxPollTimeMs = getMaxExecutionTimeout() + const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs) + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const result = await getPage() + const jobStatus = result.JobStatus + + if (jobStatus === 'SUCCEEDED' || jobStatus === 'PARTIAL_SUCCESS') { + if (jobStatus === 'PARTIAL_SUCCESS') { + logger.warn(`[${requestId}] Job completed with partial success: ${result.StatusMessage}`) + } else { + logger.info(`[${requestId}] Async job completed successfully after ${attempt + 1} polls`) + } + + let merged = result + let nextToken = result.NextToken + while (nextToken) { + const page = await getPage(nextToken) + merged = mergePage(merged, page) + nextToken = page.NextToken + } + return merged + } + + if (jobStatus === 'FAILED') { + throw new TextractRouteError( + `Textract job failed: ${result.StatusMessage || 'Unknown error'}`, + 502 + ) + } + + logger.info(`[${requestId}] Job status: ${jobStatus}, attempt ${attempt + 1}/${maxAttempts}`) + await sleep(pollIntervalMs) + } + + throw new TextractRouteError( + `Timeout waiting for Textract job to complete (max ${maxPollTimeMs / 1000} seconds)`, + 504 + ) +} diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 7ad630c5ff6..92506cc8627 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -223,69 +223,21 @@ export const DELETE = withRouteHandler( await assertWorkflowMutable(webhookData.workflow.id) const foundWebhook = webhookData.webhook - const credentialSetId = foundWebhook.credentialSetId as string | undefined - const blockId = foundWebhook.blockId as string | undefined - - if (credentialSetId && blockId) { - const allCredentialSetWebhooks = await db - .select() - .from(webhook) - .where( - and( - eq(webhook.workflowId, webhookData.workflow.id), - eq(webhook.blockId, blockId), - isNull(webhook.archivedAt) - ) - ) - - const webhooksToDelete = allCredentialSetWebhooks.filter( - (w) => w.credentialSetId === credentialSetId - ) - - for (const w of webhooksToDelete) { - await cleanupExternalWebhook(w, webhookData.workflow, requestId) - } - - const idsToDelete = webhooksToDelete.map((w) => w.id) - for (const wId of idsToDelete) { - await db.delete(webhook).where(eq(webhook.id, wId)) - } - - try { - for (const wId of idsToDelete) { - PlatformEvents.webhookDeleted({ - webhookId: wId, - workflowId: webhookData.workflow.id, - }) - } - } catch { - // Telemetry should not fail the operation - } - - logger.info( - `[${requestId}] Successfully deleted ${idsToDelete.length} webhooks for credential set`, - { - credentialSetId, - blockId, - deletedIds: idsToDelete, - } - ) - } else { - await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId) - await db.delete(webhook).where(eq(webhook.id, id)) - - try { - PlatformEvents.webhookDeleted({ - webhookId: id, - workflowId: webhookData.workflow.id, - }) - } catch { - // Telemetry should not fail the operation - } - - logger.info(`[${requestId}] Successfully deleted webhook: ${id}`) + + await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId) + await db.delete(webhook).where(eq(webhook.id, id)) + + try { + PlatformEvents.webhookDeleted({ + webhookId: id, + workflowId: webhookData.workflow.id, + }) + } catch { + // Telemetry should not fail the operation } + logger.info(`[${requestId}] Successfully deleted webhook: ${id}`) + recordAudit({ workspaceId: webhookData.workflow.workspaceId || null, actorId: userId, @@ -301,7 +253,6 @@ export const DELETE = withRouteHandler( workflowId: webhookData.workflow.id, webhookPath: foundWebhook.path || undefined, blockId: foundWebhook.blockId || undefined, - credentialSetId: credentialSetId || undefined, }, request, }) diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index b31ae2563a9..0dc99567746 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -152,8 +152,8 @@ export const POST = withRouteHandler(async (req: Request) => { return NextResponse.json({ ok: true }) } - const fromEmail = extractSenderEmail(message.from_) || '' - logger.info('Webhook received', { fromEmail, from_raw: message.from_, workspaceId: result.id }) + const fromEmail = extractSenderEmail(message.from) || '' + logger.info('Webhook received', { fromEmail, from_raw: message.from, workspaceId: result.id }) if (result.inboxAddress && fromEmail === result.inboxAddress.toLowerCase()) { logger.info('Skipping email from inbox itself', { workspaceId: result.id }) @@ -212,7 +212,7 @@ export const POST = withRouteHandler(async (req: Request) => { const chatId = parentTaskResult[0]?.chatId ?? null - const fromName = extractDisplayName(message.from_) + const fromName = extractDisplayName(message.from) const taskId = generateId() const bodyText = message.text?.substring(0, 50_000) || null @@ -334,8 +334,8 @@ async function createRejectedTask( await db.insert(mothershipInboxTask).values({ id: generateId(), workspaceId, - fromEmail: extractSenderEmail(message.from_) || 'unknown', - fromName: extractDisplayName(message.from_), + fromEmail: extractSenderEmail(message.from) || 'unknown', + fromName: extractDisplayName(message.from), subject: message.subject || '(no subject)', bodyPreview: (message.text || '').substring(0, 200) || null, emailMessageId: message.message_id, @@ -347,7 +347,7 @@ async function createRejectedTask( } /** - * Extract the raw email address from AgentMail's from_ field. + * Extract the raw email address from AgentMail's from field. * Format: "username@domain.com" or "Display Name " */ function extractSenderEmail(from: string): string { diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 8c7fdec2ee8..a27b0d599b0 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -17,7 +17,6 @@ import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getProviderIdFromServiceId } from '@/lib/oauth' import { captureServerEvent } from '@/lib/posthog/server' import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver' import { @@ -27,12 +26,8 @@ import { } from '@/lib/webhooks/provider-subscriptions' import { getProviderHandler } from '@/lib/webhooks/providers' import { mergeNonUserFields } from '@/lib/webhooks/utils' -import { - findConflictingWebhookPathOwner, - syncWebhooksForCredentialSet, -} from '@/lib/webhooks/utils.server' +import { findConflictingWebhookPathOwner } from '@/lib/webhooks/utils.server' import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' -import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants' const logger = createLogger('WebhooksAPI') @@ -52,7 +47,6 @@ async function revertSavedWebhook( path: existingWebhook.path, provider: existingWebhook.provider, providerConfig: existingWebhook.providerConfig, - credentialSetId: existingWebhook.credentialSetId, isActive: existingWebhook.isActive, archivedAt: existingWebhook.archivedAt, updatedAt: existingWebhook.updatedAt, @@ -367,144 +361,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workflowRecord.workspaceId || undefined ) - // For credential sets, we fan out to create one webhook per credential at save time. - // This applies to all OAuth-based triggers, not just polling ones. - // Check for credentialSetId directly (frontend may already extract it) or credential set value in credential fields - const rawCredentialId = (resolvedProviderConfig?.credentialId || - resolvedProviderConfig?.triggerCredentials) as string | undefined - const directCredentialSetId = resolvedProviderConfig?.credentialSetId as string | undefined - - if (directCredentialSetId || rawCredentialId) { - const credentialSetId = - directCredentialSetId || - (rawCredentialId && isCredentialSetValue(rawCredentialId) - ? extractCredentialSetId(rawCredentialId) - : null) - - if (credentialSetId) { - logger.info( - `[${requestId}] Credential set detected for ${provider} trigger. Syncing webhooks for set ${credentialSetId}` - ) - - const oauthProviderId = getProviderIdFromServiceId(provider) - - const { - credentialId: _cId, - triggerCredentials: _tCred, - credentialSetId: _csId, - ...baseProviderConfig - } = resolvedProviderConfig - - try { - const syncResult = await syncWebhooksForCredentialSet({ - workflowId, - blockId: blockId || '', - provider, - basePath: finalPath, - credentialSetId, - oauthProviderId, - providerConfig: baseProviderConfig, - requestId, - }) - - if (syncResult.webhooks.length === 0) { - logger.error( - `[${requestId}] No webhooks created for credential set - no valid credentials found` - ) - return NextResponse.json( - { - error: `No valid credentials found in credential set for ${provider}`, - details: 'Please ensure team members have connected their accounts', - }, - { status: 400 } - ) - } - - const providerHandler = getProviderHandler(provider) - - if (providerHandler.configurePolling) { - const configureErrors: string[] = [] - - for (const wh of syncResult.webhooks) { - if (wh.isNew) { - const webhookRows = await db - .select() - .from(webhook) - .where(and(eq(webhook.id, wh.id), isNull(webhook.archivedAt))) - .limit(1) - - if (webhookRows.length > 0) { - const success = await providerHandler.configurePolling({ - webhook: webhookRows[0], - requestId, - }) - if (!success) { - configureErrors.push( - `Failed to configure webhook for credential ${wh.credentialId}` - ) - logger.warn( - `[${requestId}] Failed to configure ${provider} polling for webhook ${wh.id}` - ) - } - } - } - } - - if ( - configureErrors.length > 0 && - configureErrors.length === syncResult.webhooks.length - ) { - logger.error(`[${requestId}] All webhook configurations failed, rolling back`) - for (const wh of syncResult.webhooks) { - await db.delete(webhook).where(eq(webhook.id, wh.id)) - } - return NextResponse.json( - { - error: `Failed to configure ${provider} polling`, - details: 'Please check account permissions and try again', - }, - { status: 500 } - ) - } - } - - logger.info( - `[${requestId}] Successfully synced ${syncResult.webhooks.length} webhooks for credential set ${credentialSetId}` - ) - - // Return the first webhook as the "primary" for the UI - // The UI will query by credentialSetId to get all of them - const primaryWebhookRows = await db - .select() - .from(webhook) - .where(and(eq(webhook.id, syncResult.webhooks[0].id), isNull(webhook.archivedAt))) - .limit(1) - - return NextResponse.json( - { - webhook: primaryWebhookRows[0], - credentialSetInfo: { - credentialSetId, - totalWebhooks: syncResult.webhooks.length, - created: syncResult.created, - updated: syncResult.updated, - deleted: syncResult.deleted, - }, - }, - { status: syncResult.created > 0 ? 201 : 200 } - ) - } catch (err) { - logger.error(`[${requestId}] Error syncing webhooks for credential set`, err) - return NextResponse.json( - { - error: `Failed to configure ${provider} webhook`, - details: getErrorMessage(err, 'Unknown error'), - }, - { status: 500 } - ) - } - } - } let externalSubscriptionCreated = false const createTempWebhookData = (providerConfigOverride = resolvedProviderConfig) => ({ id: targetWebhookId || generateShortId(), @@ -580,8 +436,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { blockId, provider, providerConfig: configToSave, - credentialSetId: - ((configToSave as Record)?.credentialSetId as string | null) || null, isActive: true, updatedAt: new Date(), }) @@ -605,8 +459,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { path: finalPath, provider, providerConfig: configToSave, - credentialSetId: - ((configToSave as Record)?.credentialSetId as string | null) || null, isActive: true, createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index f43807815bc..1c103ddad72 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -104,7 +104,7 @@ async function handleWebhookPost( return challengeResponse } - // Find all webhooks for this path (supports credential set fan-out where multiple webhooks share a path) + // Find all webhooks for this path (multiple webhooks in one workflow may share a path) const allWebhooksForPath = await findAllWebhooksForPath({ requestId, path }) // Internal trigger providers (sim, table) are fired in-process, never over @@ -134,8 +134,7 @@ async function handleWebhookPost( return new NextResponse('Not Found', { status: 404 }) } - // Process each webhook - // For credential sets with shared paths, each webhook represents a different credential + // Process each webhook matched on this path const responses: NextResponse[] = [] let billingBlocked = false @@ -229,9 +228,7 @@ async function handleWebhookPost( } // For multiple webhooks, return success if at least one succeeded - logger.info( - `[${requestId}] Processed ${responses.length} webhooks for path: ${path} (credential set fan-out)` - ) + logger.info(`[${requestId}] Processed ${responses.length} webhooks for path: ${path}`) return NextResponse.json({ success: true, webhooksProcessed: responses.length, diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 43a450765b5..3086192a6eb 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -13,11 +13,9 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import { captureServerEvent } from '@/lib/posthog/server' import { - checkWorkspaceAccess, - getUserEntityPermissions, getUsersWithPermissions, + getWorkspacePermissionsForViewer, hasWorkspaceAdminAccess, - type PermissionType, } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspacesPermissionsAPI') @@ -41,37 +39,13 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const isAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId) - const access = await checkWorkspaceAccess(workspaceId, session.user.id) + const result = await getWorkspacePermissionsForViewer(workspaceId, session.user.id) - if (!access.exists) { + if (!result) { return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) } - if (!isAdmin && !access.hasAccess) { - return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) - } - - const explicitPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - const viewerPermissionType: PermissionType = isAdmin - ? 'admin' - : (explicitPermission ?? 'read') - - const result = await getUsersWithPermissions(workspaceId) - - return NextResponse.json({ - users: result, - total: result.length, - viewer: { - userId: session.user.id, - isAdmin, - permissionType: viewerPermissionType, - }, - }) + return NextResponse.json(result) } catch (error) { logger.error('Error fetching workspace permissions:', error) return NextResponse.json({ error: 'Failed to fetch workspace permissions' }, { status: 500 }) diff --git a/apps/sim/app/credential-account/[token]/loading.tsx b/apps/sim/app/credential-account/[token]/loading.tsx deleted file mode 100644 index 275aa3b854d..00000000000 --- a/apps/sim/app/credential-account/[token]/loading.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Skeleton } from '@sim/emcn' - -export default function CredentialAccountLoading() { - return ( -
-
-
-
- - - - - -
-
-
-
- ) -} diff --git a/apps/sim/app/credential-account/[token]/page.tsx b/apps/sim/app/credential-account/[token]/page.tsx deleted file mode 100644 index a9ba15dca2a..00000000000 --- a/apps/sim/app/credential-account/[token]/page.tsx +++ /dev/null @@ -1,275 +0,0 @@ -'use client' - -import { useCallback, useEffect, useState } from 'react' -import { createLogger } from '@sim/logger' -import { Mail } from 'lucide-react' -import { useParams, useRouter } from 'next/navigation' -import { GmailIcon, OutlookIcon } from '@/components/icons' -import { ApiClientError } from '@/lib/api/client/errors' -import { requestJson } from '@/lib/api/client/request' -import { - acceptCredentialSetInvitationContract, - type CredentialSetInvitePreview, - getCredentialSetInvitationContract, -} from '@/lib/api/contracts' -import { listOAuthConnectionsContract } from '@/lib/api/contracts/oauth-connections' -import { client, useSession } from '@/lib/auth/auth-client' -import { getProviderDisplayName, isPollingProvider } from '@/lib/credential-sets/providers' -import { InviteLayout, InviteStatusCard } from '@/app/invite/components' - -const logger = createLogger('CredentialAccount') - -type AcceptedState = 'connecting' | 'already-connected' - -function getErrorMessageFromBody(body: unknown): string | undefined { - if (!body || typeof body !== 'object') return undefined - const error = (body as { error?: unknown }).error - return typeof error === 'string' ? error : undefined -} - -export default function CredentialAccountInvitePage() { - const params = useParams() - const router = useRouter() - const token = params.token as string - - const { data: session, isPending: sessionLoading } = useSession() - - const [invitation, setInvitation] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [accepting, setAccepting] = useState(false) - const [acceptedState, setAcceptedState] = useState(null) - - useEffect(() => { - async function fetchInvitation() { - try { - const data = await requestJson(getCredentialSetInvitationContract, { - params: { token }, - }) - setInvitation(data.invitation) - } catch (fetchError) { - if (fetchError instanceof ApiClientError) { - setError(getErrorMessageFromBody(fetchError.body) || fetchError.message) - } else { - setError('Failed to load invitation') - } - } finally { - setLoading(false) - } - } - - fetchInvitation() - }, [token]) - - const handleAccept = useCallback(async () => { - if (!session?.user?.id) { - // Include invite_flow=true so the login page preserves callbackUrl when linking to signup - const callbackUrl = encodeURIComponent(`/credential-account/${token}`) - router.push(`/login?invite_flow=true&callbackUrl=${callbackUrl}`) - return - } - - setAccepting(true) - try { - const acceptResponse = await requestJson(acceptCredentialSetInvitationContract, { - params: { token }, - }).catch((acceptError: unknown) => { - const fallback = 'Failed to accept invitation' - if (acceptError instanceof ApiClientError) { - setError(getErrorMessageFromBody(acceptError.body) || acceptError.message || fallback) - } else { - setError(fallback) - } - return null - }) - - if (!acceptResponse) { - return - } - - const credentialSetProviderId = acceptResponse.providerId || invitation?.providerId - - // Check if user already has this provider connected - let isAlreadyConnected = false - if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) { - try { - const connectionsData = await requestJson(listOAuthConnectionsContract, {}) - isAlreadyConnected = (connectionsData.connections ?? []).some( - (conn) => - conn.provider === credentialSetProviderId && conn.accounts && conn.accounts.length > 0 - ) - } catch { - // If we can't check connections, proceed with OAuth flow - } - } - - if (isAlreadyConnected) { - // Already connected - redirect to workspace - setAcceptedState('already-connected') - setTimeout(() => { - router.push('/workspace') - }, 2000) - } else if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) { - // Not connected - start OAuth flow - setAcceptedState('connecting') - - // Small delay to show success message before redirect - setTimeout(async () => { - try { - await client.oauth2.link({ - providerId: credentialSetProviderId, - callbackURL: `${window.location.origin}/workspace`, - }) - } catch (oauthError) { - // OAuth redirect will happen, this catch is for any pre-redirect errors - logger.error('OAuth initiation error:', oauthError) - // If OAuth fails, redirect to workspace where they can connect manually - router.push('/workspace') - } - }, 1500) - } else { - // No provider specified - just redirect to workspace - router.push('/workspace') - } - } catch { - setError('Failed to accept invitation') - } finally { - setAccepting(false) - } - }, [session?.user?.id, token, router, invitation?.providerId]) - - const providerName = invitation?.providerId - ? getProviderDisplayName(invitation.providerId) - : 'email' - - const ProviderIcon = - invitation?.providerId === 'outlook' - ? OutlookIcon - : invitation?.providerId === 'google-email' - ? GmailIcon - : Mail - - const providerWithIcon = ( - - - {providerName} - - ) - - const getCallbackUrl = () => `/credential-account/${token}` - - if (loading || sessionLoading) { - return ( - - - - ) - } - - if (error) { - return ( - - router.push('/'), - }, - ]} - /> - - ) - } - - if (acceptedState === 'already-connected') { - return ( - - - - ) - } - - if (acceptedState === 'connecting') { - return ( - - - - ) - } - - // Not logged in - if (!session?.user) { - const callbackUrl = encodeURIComponent(getCallbackUrl()) - - return ( - - router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`), - }, - { - label: 'Create an account', - onClick: () => - router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`), - }, - { - label: 'Return to Home', - onClick: () => router.push('/'), - }, - ]} - /> - - ) - } - - // Logged in - show invitation - return ( - - - You've been invited to join {invitation?.credentialSetName} by{' '} - {invitation?.organizationName}. - {invitation?.providerId && ( - <> You'll be asked to connect your {providerWithIcon} account after accepting. - )} - - } - icon='mail' - actions={[ - { - label: `Accept & Connect ${providerName}`, - onClick: handleAccept, - disabled: accepting, - loading: accepting, - }, - { - label: 'Return to Home', - onClick: () => router.push('/'), - }, - ]} - /> - - ) -} diff --git a/apps/sim/app/robots.ts b/apps/sim/app/robots.ts index e2aef2bb753..ca4b523265f 100644 --- a/apps/sim/app/robots.ts +++ b/apps/sim/app/robots.ts @@ -9,7 +9,6 @@ const DISALLOWED_PATHS = [ '/invite/', '/unsubscribe/', '/w/', - '/credential-account/', '/_next/', '/private/', ] diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx index 0bac2180bed..e4ba8033261 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx @@ -7,7 +7,11 @@ import { ZoomablePreview } from './zoomable-preview' export const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) { const source = useFileContentSource() - const serveUrl = source.buildUrl(file.key) + // Version the URL on updatedAt: overwrites keep the same storage key, so an unversioned + // URL would resolve to a previously cached copy instead of the rewritten bytes. + const serveUrl = source.buildUrl(file.key, { + version: Number(new Date(file.updatedAt)) || file.size, + }) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index 205da75a97d..01a2e2c82dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -9,6 +9,7 @@ import { RichMarkdownKeymap } from './keymap' import { MarkdownPaste } from './markdown-paste' import { Mention } from './mention/mention' import { MentionChip } from './mention/mention-chip' +import { FootnoteDefWithView, RawHtmlBlockWithView } from './raw-markdown-snippet' import { SlashCommand } from './slash-command/slash-command' interface MarkdownEditorExtensionOptions { @@ -36,6 +37,8 @@ export function createMarkdownEditorExtensions({ codeBlock: CodeBlockWithLanguage, image: ResizableImage, mention: MentionChip, + rawHtmlBlock: RawHtmlBlockWithView, + footnoteDef: FootnoteDefWithView, }), CodeBlockHighlight, SlashCommand, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index c99abf36c1c..1f29545bebb 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -15,6 +15,7 @@ import { MarkdownImage } from './image' import { MarkdownLinkInputRule } from './link-input-rule' import { MarkdownMention } from './mention/mention-node' import { SIM_LINK_SCHEME } from './mention/sim-link' +import { FootnoteDef, FootnoteRef, RawHtmlBlock, RawInlineHtml } from './raw-markdown-snippet' /** * The `@`-mention link scheme, registered on the Link mark — without it the schema strips the @@ -66,6 +67,8 @@ export interface ContentNodeViews { codeBlock?: Node image?: Node mention?: Node + rawHtmlBlock?: Node + footnoteDef?: Node } /** @@ -100,6 +103,10 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {} TableRow, TableHeader, TableCell, + nodeViews.rawHtmlBlock ?? RawHtmlBlock, + nodeViews.footnoteDef ?? FootnoteDef, + FootnoteRef, + RawInlineHtml, MarkdownLinkInputRule, Markdown, ] diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts index 249f26512f5..b36857c037e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts @@ -16,8 +16,8 @@ afterEach(() => { editor = null }) -function mount(): Editor { - return new Editor({ extensions: [...createMarkdownContentExtensions(), MarkdownPaste] }) +function mount(editable = true): Editor { + return new Editor({ extensions: [...createMarkdownContentExtensions(), MarkdownPaste], editable }) } /** Run the plugin paste handlers the way ProseMirror would, with a mocked clipboard. */ @@ -58,9 +58,32 @@ describe('markdown paste', () => { expect(paste(editor, 'just a normal sentence with no syntax')).toBe(false) }) - it('does not markdown-parse a paste that carries richer HTML', () => { + it("prefers the markdown parser over DOM mapping when the HTML sibling's plain-text side also looks like markdown", () => { editor = mount() - expect(paste(editor, '# heading', '

heading

')).toBe(false) + expect(paste(editor, '# heading', '

heading

')).toBe(true) + const json = JSON.stringify(editor.getJSON()) + expect(json).toContain('"type":"heading"') + }) + + it('preserves GFM table alignment on a paste that carries both text/plain and text/html', () => { + editor = mount() + const table = '| a | b |\n| :-- | --: |\n| 1 | 2 |' + const html = '
ab
12
' + expect(paste(editor, table, html)).toBe(true) + const json = JSON.stringify(editor.getJSON()) + expect(json).toContain('"align":"left"') + expect(json).toContain('"align":"right"') + }) + + it('still defers to DOM mapping when the HTML sibling has no markdown-shaped plain-text counterpart', () => { + editor = mount() + expect( + paste( + editor, + 'just a normal sentence with no syntax', + '

just a normal sentence with no syntax

' + ) + ).toBe(false) }) it('keeps pasted markdown literal inside a code block', () => { @@ -70,4 +93,89 @@ describe('markdown paste', () => { expect(editor.isActive('codeBlock')).toBe(true) expect(paste(editor, '[link](https://example.com)')).toBe(false) }) + + it('keeps pasted markdown literal inside inline code', () => { + editor = mount() + editor.commands.setContent('a `codehere` b', { contentType: 'markdown' }) + editor.commands.setTextSelection(6) + expect(editor.isActive('code')).toBe(true) + expect(paste(editor, '*italic*')).toBe(false) + }) + + it('rejects the paste entirely in a read-only editor', () => { + editor = mount(false) + expect(paste(editor, '# heading\n\n- one\n- two')).toBe(false) + expect(editor.getText()).toBe('') + }) + + it.each([ + ['empty string', ''], + ['whitespace only', ' \n\n '], + ['a bare thematic break (ambiguous — needs another markdown signal)', '---'], + ])('leaves %s to the default handler', (_label, text) => { + editor = mount() + expect(paste(editor, text)).toBe(false) + }) + + it.each([ + ['heading', '# Heading', 'heading'], + ['bold', 'a **bold** word', 'bold'], + ['italic', 'an *italic* word', 'italic'], + ['underscore italic', 'an _italic_ word', 'italic'], + ['underscore bold', 'a __bold__ word', 'bold'], + ['strikethrough', 'a ~~struck~~ word', 'strike'], + ['inline code', 'some `code` here', 'code'], + ['bullet list', '- one\n- two', 'bulletList'], + ['ordered list', '1. one\n2. two', 'orderedList'], + ['task list', '- [x] done\n- [ ] todo', 'taskList'], + ['blockquote', '> a quote', 'blockquote'], + ['fenced code block', '```ts\nconst x = 1\n```', 'codeBlock'], + ['standalone image', '![alt](https://e.com/i.png)', 'image'], + ['thematic break within a document', '# Title\n\n---\n\nbody', 'horizontalRule'], + ])('renders pasted %s as rich content', (_label, md, nodeType) => { + editor = mount() + expect(paste(editor, md)).toBe(true) + expect(JSON.stringify(editor.getJSON())).toContain(`"type":"${nodeType}"`) + }) + + it.each([ + ['italic', 'an *italic* word', '

an italic word

'], + ['strikethrough', 'a ~~struck~~ word', '

a struck word

'], + ['inline code', 'some `code` here', '

some code here

'], + ])('defers inline-only %s to a rich HTML sibling (keeps its structure)', (_label, text, html) => { + editor = mount() + expect(paste(editor, text, html)).toBe(false) + }) + + it.each([ + ['space-flanked asterisks', 'area = 5 * width * height'], + ['python args and kwargs', 'def foo(*args, **kwargs): pass'], + ['snake_case identifiers', 'call user_name and file_path_here'], + ])('claims %s but leaves it byte-for-byte literal (strict CommonMark)', (_label, text) => { + editor = mount() + expect(paste(editor, text)).toBe(true) + const json = JSON.stringify(editor.getJSON()) + expect(json).not.toContain('"type":"italic"') + expect(json).not.toContain('"type":"bold"') + expect(editor.getText()).toBe(text) + }) + + it('parses markdown-shaped plain text even when an HTML sibling is present', () => { + editor = mount() + const html = '

Title

  • a
  • b
' + expect(paste(editor, '# Title\n\n- a\n- b', html)).toBe(true) + const json = JSON.stringify(editor.getJSON()) + expect(json).toContain('"type":"heading"') + expect(json).toContain('"type":"bulletList"') + expect(json).not.toContain('# Title') + }) + + it('preserves the structural blocks of a multi-block document, in order, on paste', () => { + editor = mount() + expect(paste(editor, '# Title\n\nA paragraph.\n\n- a\n- b\n\n> quote')).toBe(true) + const structural = (editor.getJSON().content ?? []) + .map((node) => node.type) + .filter((type) => type !== 'paragraph') + expect(structural).toEqual(['heading', 'bulletList', 'blockquote']) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts index 808c0fa377e..88a9debbd9a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts @@ -3,11 +3,12 @@ import { Plugin } from '@tiptap/pm/state' import { parseMarkdownToDoc } from './markdown-parse' /** - * Markdown syntax hints. If pasted plain text matches any of these, it's parsed as markdown rather - * than inserted literally — so a pasted link, image, badge, list, or heading renders as rich content - * instead of showing its raw `[text](url)` / `# ` source. + * Structural markdown — strong signals the plain text is genuinely markdown (a link, image, badge, + * list, heading, blockquote, fenced block, or GFM table). Our parser round-trips these more faithfully + * than generic HTML→DOM mapping (GFM alignment, escaping, the `./raw-markdown-snippet.ts` constructs), + * so they are parsed even when the clipboard also carries an HTML sibling. */ -const MARKDOWN_HINTS: ReadonlyArray = [ +const STRUCTURAL_MARKDOWN_HINTS: ReadonlyArray = [ /^#{1,6}\s/m, /\*\*[^*]+\*\*/, /\[[^\]]*]\([^)]+\)/, @@ -18,14 +19,40 @@ const MARKDOWN_HINTS: ReadonlyArray = [ /^\|.*\|.*\|/m, ] -function looksLikeMarkdown(text: string): boolean { - return MARKDOWN_HINTS.some((hint) => hint.test(text)) +/** + * Inline marks — weaker markdown signals (`*italic*` / `_italic_`, `~~strike~~`, `` `code` ``) that a + * rich HTML sibling encodes just as well. Parsed for a plain-text-only paste (so markdown copied from a + * terminal or `.md` source renders), but deferred to an HTML sibling: its presence means the source was + * rich, and it may carry structure the plain text can't (a copied table's plain form is tab-separated, + * not a `| … |` grid, so parsing it would flatten the table). + */ +const INLINE_MARK_HINTS: ReadonlyArray = [ + /\*[^*\n]+\*/, + /_[^_\n]+_/, + /~~[^~\n]+~~/, + /`[^`\n]+`/, +] + +function hasAny(hints: ReadonlyArray, text: string): boolean { + return hints.some((hint) => hint.test(text)) } /** - * Parses pasted plain text that looks like markdown into rich content. Pastes inside a code block - * are left untouched (code is meant to stay literal), as are pastes that carry richer HTML — those - * are handled by ProseMirror's own clipboard parsing. + * Parses pasted plain text that looks like markdown into rich content, via the strict CommonMark + * parser ({@link parseMarkdownToDoc}, `marked`). Pastes inside a code block or inline code are left + * untouched (code is meant to stay literal). + * + * Provenance decides plain-text-vs-HTML: a `text/html` sibling (copied from a browser, Slack, Notion, + * GitHub, or this editor) is the signal the source was rich. Structural markdown is still parsed from + * the plain-text sibling regardless — our parser is more faithful for GFM tables and escaping. But + * inline-only marks are equally expressible in HTML, so when a rich sibling is present we defer to the + * DOM path, which preserves structure the plain text can't encode. A plain-text-only clipboard (a + * terminal, a code editor, a `.md` file) always parses. + * + * The strictness of the parse matters: `marked` follows CommonMark flanking rules, so `*text*` becomes + * emphasis but a space-flanked `5 * width * height` stays literal. The editor sets `enablePasteRules: + * false` so StarterKit's lenient mark paste rules (which would mangle that expression on either path) + * never run — emphasis is owned by this parser on the plain path and by real HTML tags on the DOM path. */ export const MarkdownPaste = Extension.create({ name: 'markdownPaste', @@ -37,13 +64,13 @@ export const MarkdownPaste = Extension.create({ props: { handlePaste: (_view, event) => { if (!editor.isEditable) return false - if (editor.isActive('codeBlock')) return false - const html = event.clipboardData?.getData('text/html') - if (html) return false + if (editor.isActive('codeBlock') || editor.isActive('code')) return false const text = event.clipboardData?.getData('text/plain') - if (!text || !looksLikeMarkdown(text)) return false - // Parse through the chunker (linear) so pasting a large markdown blob can't freeze the - // main thread the way the underlying superlinear parse would. + if (!text) return false + if (!hasAny(STRUCTURAL_MARKDOWN_HINTS, text)) { + if (!hasAny(INLINE_MARK_HINTS, text)) return false + if (event.clipboardData?.getData('text/html')) return false + } const doc = parseMarkdownToDoc(text) if (!doc.content?.length) return false return editor.commands.insertContent(doc) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts new file mode 100644 index 00000000000..8c4346499e5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment jsdom + * + * `TableBubbleMenu` (table-menu.tsx) is a thin UI wrapper around `@tiptap/extension-table`'s stock + * commands — the button that matters is the command it calls, not the floating-toolbar chrome. These + * exercise the exact commands the toolbar wires up (`addRowBefore`/`addRowAfter`/`deleteRow`, + * `addColumnBefore`/`addColumnAfter`/`deleteColumn`, `toggleHeaderRow`, `deleteTable`) against a real + * editor and assert the result round-trips through `PipeSafeTable` to clean, correctly-shaped GFM. + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownContentExtensions } from '../extensions' + +let editor: Editor | null = null +afterEach(() => { + editor?.destroy() + editor = null +}) + +function mount(markdown: string): Editor { + return new Editor({ + extensions: createMarkdownContentExtensions(), + content: markdown, + contentType: 'markdown', + }) +} + +function firstCellPos(ed: Editor): number { + let pos = -1 + ed.state.doc.descendants((node, p) => { + if (pos < 0 && (node.type.name === 'tableCell' || node.type.name === 'tableHeader')) pos = p + 1 + }) + return pos +} + +describe('table toolbar commands', () => { + it('inserts a row after the current row and it round-trips as a clean GFM table', () => { + editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |') + editor.commands.setTextSelection(firstCellPos(editor)) + expect(editor.commands.addRowAfter()).toBe(true) + + const rows = editor.state.doc.firstChild + expect(rows?.type.name).toBe('table') + expect(rows?.childCount).toBe(3) // header + original row + inserted row + + const md = editor.getMarkdown().trim() + expect(md.split('\n')).toHaveLength(4) + expect(md).toContain('| a') + expect(md).toContain('| --- | --- |') + }) + + it('inserts a row before the current row', () => { + editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |') + editor.commands.setTextSelection(firstCellPos(editor)) + expect(editor.commands.addRowBefore()).toBe(true) + expect(editor.state.doc.firstChild?.childCount).toBe(3) + }) + + it('deletes the current row', () => { + editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |') + // Select the second body row (skip header). + let pos = -1 + let seen = 0 + editor.state.doc.descendants((node, p) => { + if (node.type.name === 'tableRow') { + seen++ + if (seen === 3) pos = p + 2 + } + }) + editor.commands.setTextSelection(pos) + expect(editor.commands.deleteRow()).toBe(true) + expect(editor.state.doc.firstChild?.childCount).toBe(2) + expect(editor.getMarkdown()).toContain('| 1 | 2 |') + expect(editor.getMarkdown()).not.toContain('3') + }) + + it('inserts and deletes a column', () => { + editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |') + editor.commands.setTextSelection(firstCellPos(editor)) + expect(editor.commands.addColumnAfter()).toBe(true) + let cols = 0 + editor.state.doc.descendants((node) => { + if (node.type.name === 'tableRow' && cols === 0) cols = node.childCount + }) + expect(cols).toBe(3) + + // The insert shifted positions — the cursor's old cell no longer maps to the same column, so + // re-select the first cell before deleting, exactly as a real user would click a cell first. + editor.commands.setTextSelection(firstCellPos(editor)) + expect(editor.commands.deleteColumn()).toBe(true) + editor.state.doc.descendants((node) => { + if (node.type.name === 'tableRow') cols = node.childCount + }) + expect(cols).toBe(2) + }) + + it('toggles the header row', () => { + editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |') + editor.commands.setTextSelection(firstCellPos(editor)) + const before = editor.isActive('tableHeader') + expect(editor.commands.toggleHeaderRow()).toBe(true) + expect(editor.isActive('tableHeader')).toBe(!before) + }) + + it('deletes the whole table', () => { + editor = mount('before\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nafter') + editor.commands.setTextSelection(firstCellPos(editor)) + expect(editor.commands.deleteTable()).toBe(true) + const types: string[] = [] + editor.state.doc.forEach((node) => types.push(node.type.name)) + expect(types).not.toContain('table') + expect(editor.getMarkdown()).toContain('before') + expect(editor.getMarkdown()).toContain('after') + }) + + it('a full add-row + add-column + delete-row sequence stays idempotent on re-serialize', () => { + editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |') + editor.commands.setTextSelection(firstCellPos(editor)) + editor.commands.addRowAfter() + editor.commands.addColumnAfter() + const once = editor.getMarkdown().trim() + const reparsed = new Editor({ + extensions: createMarkdownContentExtensions(), + content: once, + contentType: 'markdown', + }) + const twice = reparsed.getMarkdown().trim() + reparsed.destroy() + expect(twice).toBe(once) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx new file mode 100644 index 00000000000..e62fa8fdcb0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx @@ -0,0 +1,128 @@ +import { useCallback, useState } from 'react' +import { posToDOMRect } from '@tiptap/core' +import { PluginKey } from '@tiptap/pm/state' +import type { Editor } from '@tiptap/react' +import { useEditorState } from '@tiptap/react' +import { BubbleMenu } from '@tiptap/react/menus' +import { + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, + Columns3, + Rows3, + Table as TableIcon, + Trash2, +} from 'lucide-react' +import { ToolbarButton, ToolbarDivider } from './toolbar-button' + +/** Pins the toolbar to the viewport instead of tracking the (often wide) table as it scrolls horizontally. */ +const FLOATING_OPTIONS = { strategy: 'fixed' } as const + +/** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */ +const APPEND_TO_BODY = () => document.body + +interface TableBubbleMenuProps { + editor: Editor + /** The editor's scrollable viewport, used to keep the toolbar on-screen for a table taller than it. */ + scrollContainerRef: React.RefObject +} + +/** + * Floating toolbar shown whenever the selection is inside a table: row/column insert-before/after, + * row/column delete, header-row toggle, and delete-table. `@tiptap/extension-table` already exposes + * all of these as editor commands (`addRowBefore`, `addColumnAfter`, …) — this is UI only, no schema + * or serializer change. + */ +export function TableBubbleMenu({ editor, scrollContainerRef }: TableBubbleMenuProps) { + const [menuKey] = useState(() => new PluginKey('markdownTableMenu')) + + const active = useEditorState({ + editor, + selector: ({ editor: e }) => ({ + headerRow: e.isActive('tableHeader'), + }), + }) + + // Recomputed on every call (not cached by selection key) — the same table cell can land at a + // different screen position purely from scrolling with no selection change, and Floating UI's + // `autoUpdate` re-invokes this on scroll/resize expecting a fresh rect each time. + const resolveAnchor = useCallback(() => { + const { view, state } = editor + if (!view.dom.isConnected) return null + const { from, to } = state.selection + const selection = posToDOMRect(view, from, to) + const viewport = scrollContainerRef.current?.getBoundingClientRect() + const rect = + viewport && selection.top < viewport.top + ? new DOMRect(selection.left, viewport.top, selection.width, 0) + : selection + return { getBoundingClientRect: () => rect, getClientRects: () => [rect] } + }, [editor, scrollContainerRef]) + + return ( + e.isEditable && e.isActive('table')} + className='fade-in-0 z-[var(--z-popover)] flex animate-in items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm duration-150 ease-out motion-reduce:animate-none' + > + editor.chain().focus().addRowBefore().run()} + /> + editor.chain().focus().addRowAfter().run()} + /> + editor.chain().focus().deleteRow().run()} + /> + + editor.chain().focus().addColumnBefore().run()} + /> + editor.chain().focus().addColumnAfter().run()} + /> + editor.chain().focus().deleteColumn().run()} + /> + + editor.chain().focus().toggleHeaderRow().run()} + /> + editor.chain().focus().deleteTable().run()} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts new file mode 100644 index 00000000000..18afe06fd51 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment jsdom + * + * Integration coverage for the *live* editor stack (`createMarkdownEditorExtensions` — the same + * extension set the real component mounts, including the React node views): raw HTML/footnote + * content renders with its wrapper class and exact source in the DOM (not just parsing correctly + * headlessly), and — the point of holding it as `content: 'text*'` rather than an opaque blob — the + * text inside is genuinely editable via a normal ProseMirror transaction, surviving serialization + * back to markdown. + */ +import { Editor } from '@tiptap/core' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownEditorExtensions } from './editor-extensions' + +let editor: Editor | null = null + +beforeEach(() => { + // The live extension set's placeholder viewport-tracking and suggestion popups use these; jsdom + // lacks them (see keymap.test.ts for the same stub). + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + Element.prototype.scrollIntoView = vi.fn() + document.elementFromPoint = vi.fn(() => null) +}) + +afterEach(() => { + editor?.destroy() + editor = null +}) + +function mount(markdown: string): Editor { + return new Editor({ + extensions: createMarkdownEditorExtensions({ placeholder: '' }), + content: markdown, + contentType: 'markdown', + }) +} + +function posOf(ed: Editor, typeName: string): number { + let pos = -1 + ed.state.doc.descendants((node, p) => { + if (pos < 0 && node.type.name === typeName) pos = p + }) + return pos +} + +/** React node views flush on a microtask after mount, so DOM assertions need one tick. */ +function nextTick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +// The hover "Raw HTML"/"Footnote" badge is rendered by `RawBlockView` through +// `ReactNodeViewRenderer`, which only flushes its React portal once `@tiptap/react`'s +// `contentComponent` is set — that requires mounting through `` (a real React render +// tree), which this repo's tests don't do for this directory (no `@testing-library/react` installed +// here) and constructing a plain `new Editor()` doesn't provide. What IS verifiable and matters more +// at this level — the node renders with the correct wrapper class and holds the exact raw source +// text — is covered below; the badge itself is decorative chrome, checked manually. +describe('raw markdown snippet node views (live editor)', () => { + it('renders a raw HTML block with the correct wrapper class and exact raw source', async () => { + editor = mount('
More\n\nbody\n\n
') + await nextTick() + const el = editor.view.dom + const block = el.querySelector('.raw-markdown-block') + expect(block).not.toBeNull() + expect(block?.textContent).toContain('
More') + }) + + it('renders a footnote definition block with the correct wrapper class and exact raw source', async () => { + editor = mount('a claim[^1]\n\n[^1]: the source') + await nextTick() + const el = editor.view.dom + const block = el.querySelector('.raw-markdown-block') + expect(block).not.toBeNull() + expect(block?.textContent).toContain('[^1]: the source') + }) + + it('renders inline raw HTML as a distinct inline chip, not a plain paragraph', async () => { + editor = mount('a Ctrl b') + await nextTick() + const el = editor.view.dom + const inline = el.querySelector('.raw-markdown-inline') + expect(inline).not.toBeNull() + expect(inline?.textContent).toBe('Ctrl') + }) + + it('the raw HTML block text is genuinely editable — a text edit round-trips into the markdown', () => { + editor = mount('
\n\ncentered\n\n
') + const pos = posOf(editor, 'rawHtmlBlock') + expect(pos).toBeGreaterThanOrEqual(0) + // Insert text right after the opening tag, simulating a user fixing the raw source in place. + const insertAt = pos + '
'.length + 1 + editor.commands.insertContentAt(insertAt, '!') + expect(editor.getMarkdown()).toContain('
!') + }) + + it('the footnote definition text is genuinely editable', () => { + editor = mount('a claim[^1]\n\n[^1]: old text') + const pos = posOf(editor, 'footnoteDef') + expect(pos).toBeGreaterThanOrEqual(0) + const node = editor.state.doc.nodeAt(pos) + const insertAt = pos + (node?.nodeSize ?? 1) - 1 + editor.commands.insertContentAt(insertAt, ' EDITED') + expect(editor.getMarkdown()).toContain('[^1]: old text EDITED') + }) + + it('a table, a raw HTML block, and a code block all coexist with working node views', async () => { + editor = mount( + '\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```' + ) + await nextTick() + const el = editor.view.dom + expect(el.querySelector('.raw-markdown-block')).not.toBeNull() + expect(el.querySelector('table')).not.toBeNull() + expect(el.querySelector('pre.code-editor-theme')).not.toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts new file mode 100644 index 00000000000..9588b487b2d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts @@ -0,0 +1,258 @@ +/** + * @vitest-environment jsdom + * + * Parse → serialize round-trip fixtures for the verbatim snippet nodes: raw HTML blocks, HTML + * comments, footnotes (def + ref), and inline raw HTML. Each must reproduce its input byte-for-byte + * and reach a fixpoint on a second pass (see `serializeMarkdownDocument` in `./markdown-parse.ts`). + */ +import { describe, expect, it } from 'vitest' +import { parseMarkdownToDoc, serializeMarkdownDocument } from './markdown-parse' + +function roundTrip(input: string): string { + return serializeMarkdownDocument(input).trim() +} + +/** Top-level node type names of the parsed doc, for structural (not just string) assertions. */ +function topLevelTypes(input: string): (string | undefined)[] { + return (parseMarkdownToDoc(input).content ?? []).map((n) => n.type) +} + +describe('raw markdown snippet nodes', () => { + it('preserves a standalone HTML comment', () => { + const input = '\n\ntext' + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('preserves a multi-line raw HTML block spanning blank lines', () => { + const input = '
More\n\nbody\n\n
' + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('preserves a raw HTML block with attributes', () => { + const input = '
\n\ncentered\n\n
' + expect(roundTrip(input)).toBe(input) + }) + + it('preserves a footnote reference and definition', () => { + const input = 'a claim[^1]\n\n[^1]: the source' + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('preserves an inline raw HTML tag the schema has no mark/node for', () => { + for (const input of ['a b c', 'press Ctrl now', 'a hit b']) { + expect(roundTrip(input)).toBe(input) + } + }) + + it('leaves recognized inline tags to their real mark (not captured as raw)', () => { + expect(roundTrip('a b c')).toBe('a *b* c') + expect(roundTrip('a b c')).toBe('a **b** c') + }) + + it('leaves a lone /
block tag to the stock image/hard-break handling', () => { + expect(roundTrip('a')).toContain('![a](/x.png)') + }) + + it('preserves a raw HTML block inside a blockquote', () => { + const input = '>
\n>\n> quoted\n>\n>
' + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('preserves a footnote reference inside a list item', () => { + const input = '- a claim[^1]\n- another line\n\n[^1]: the source' + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('does not interfere with an adjacent table or code block', () => { + const input = + '\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```' + expect(roundTrip(input)).toBe(input) + }) + + it('preserves a footnote definition with an indented continuation line', () => { + const input = 'a claim[^1]\n\n[^1]: the source\n continued here' + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('preserves a footnote definition with a blank line between continuation paragraphs', () => { + const input = 'a claim[^1]\n\n[^1]: first paragraph\n\n second paragraph' + expect(roundTrip(input)).toBe(input) + }) + + it('does not swallow the next block into a footnote definition without continuation', () => { + const input = 'a claim[^1]\n\n[^1]: the source\n\nafter' + const out = roundTrip(input) + expect(out).toContain('[^1]: the source') + expect(out).toContain('after') + }) + + it('preserves nested same-tag inline HTML (balanced close, not first-match)', () => { + const input = 'a outer inner b' + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('preserves a self-closing same-name tag nested inside an inline HTML element', () => { + const input = 'a beforeafter b' + expect(roundTrip(input)).toBe(input) + }) +}) + +describe('raw HTML block: does not fragment across blank lines', () => { + it('a
block with a blank-line-separated body is ONE node, not three', () => { + const input = + '
\nClick to expand\n\nThis is inside a details/summary block.\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('a
with multiple blank-line-separated paragraphs inside is ONE node', () => { + const input = '
\n\nfirst paragraph\n\nsecond paragraph\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('nested same-tag block HTML balances depth across blank lines', () => { + const input = '
\nouter\n\n
\n\ninner\n\n
\n\nstill outer\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('a paragraph starting with a non-block-list inline tag is NOT captured as a raw block', () => { + // `em`/`a` aren't in the CommonMark block-HTML tag whitelist — they can legitimately start an + // ordinary paragraph, and must keep parsing as real marks, not freeze as raw source. + expect(topLevelTypes('hi there, this is a normal paragraph')).toEqual(['paragraph']) + expect(roundTrip('hi there')).toBe('*hi* there') + }) + + it('a stray inline-only tag alone on its own line is left to the stock (non-whitelisted) path', () => { + // `` isn't in the block whitelist, so the new block tokenizer must not claim it — it falls + // through to marked's own (stricter) block-HTML detection, unaffected by this change. + const input = '\n\nnot a block-html tag\n\n' + expect(() => roundTrip(input)).not.toThrow() + }) + + it('an unterminated block tag falls back gracefully (no crash, no infinite loop)', () => { + const input = '
\nnever closed\n\nbody' + expect(() => roundTrip(input)).not.toThrow() + }) + + it('a block comment spanning blank lines still round-trips via the new shared tokenizer path', () => { + const input = '\n\ntext after' + expect(topLevelTypes(input)[0]).toBe('rawHtmlBlock') + expect(roundTrip(input)).toBe(input) + }) + + it('a table and code block adjacent to a fragmenting-prone details block still coexist correctly', () => { + const input = + '
\ns\n\nbody\n\n
\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```' + expect(roundTrip(input)).toBe(input) + }) + + it('preserves an indented (up to 3 spaces) block-HTML opening line', () => { + // `roundTrip`'s `.trim()` would strip the very leading indent this test verifies, so check the + // parsed node's own text (and the untrimmed serialization) instead of the trimmed helper. + for (const indent of [' ', ' ', ' ']) { + const input = `${indent}
\nx\n\nbody\n\n
` + const doc = parseMarkdownToDoc(input) + expect(doc.content?.map((n) => n.type)).toEqual(['rawHtmlBlock']) + expect(doc.content?.[0].content?.[0].text).toBe(input) + expect(serializeMarkdownDocument(input)).toBe(`${input}\n`) + } + }) + + it('preserves a quoted attribute value containing a literal >', () => { + const input = '
\n\ncontent\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('a quoted attribute containing a nested same-tag mention does not confuse the balance scan', () => { + // Without attribute-aware matching, `
` inside the quoted value below would be miscounted as + // a real nested open tag, throwing off the depth count entirely. + const input = '
\n\ncontent\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('does not mistake a tag name mentioned inside an inline code span for a real closing tag', () => { + const input = '
\nx\n\nSee `
` in the docs.\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('does not mistake a tag name mentioned inside a fenced code block for a real closing tag', () => { + const input = '
\n\nExample:\n\n```html\n
example
\n```\n\nmore body\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('does not mistake a tag name mentioned inside an HTML comment for a real closing tag', () => { + const input = '
\n\n\n\nmore body\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('a bare (unescaped, un-fenced) tag-name mention never crashes and always converges to a stable save', () => { + // Known, inherent limitation of regex-based (non-DOM) tag matching, shared by any HTML-block + // scanner (and by real HTML parsers given the same ambiguous input) — a bare mention outside + // code can still be misread as the real closer. The bar this file holds itself to is: never + // crash, never lose text, and always settle to a fixpoint after one save (isRoundTripSafe's own + // documented tolerance for single-pass normalization) — not a perfect, DOM-aware parse. + const input = + '
\nx\n\nSee the literal text
in docs.\n\nmore body\n\n
' + expect(() => roundTrip(input)).not.toThrow() + const once = roundTrip(input) + const twice = roundTrip(once) + expect(once).toBe(twice) + // No word from the original is dropped, even though the structure/whitespace may be reflowed. + for (const word of ['See', 'the', 'literal', 'text', 'in', 'docs', 'more', 'body']) { + expect(once).toContain(word) + } + }) + + it('does not mistake a tag name mentioned inside a blockquoted fenced code block for a real closing tag', () => { + const input = + '
\n\n> Example:\n>\n> ```html\n>
example
\n> ```\n\nmore body\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('does not mistake a tag name mentioned inside an indented (no blockquote) fenced code block for a real closing tag', () => { + const input = + '
\n\nExample:\n\n ```html\n
example
\n ```\n\nmore body\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('treats a void block tag (no closing tag exists) as complete right after the open tag', () => { + // `link`/`meta`/`base`/`hr` are in the CommonMark block-HTML whitelist but are void elements — + // scanning for a `` that will never legitimately appear would risk grabbing unrelated + // later content (or a stray same-name mention) into the block. + for (const input of [ + '\n\nafter', + '\n\nafter', + '
\n\nafter', + ]) { + const doc = parseMarkdownToDoc(input) + expect(doc.content?.[0].type).toBe('rawHtmlBlock') + expect(roundTrip(input)).toContain('after') + } + }) + + it('a void block tag does not swallow a later, unrelated mention of its own tag name', () => { + const input = '\n\nSee the `` tag in docs.\n\nmore body' + const doc = parseMarkdownToDoc(input) + // The is its own complete block; the later mention (in code) stays in a separate paragraph. + expect(doc.content?.[0].type).toBe('rawHtmlBlock') + expect(doc.content?.[0].content?.[0].text).toBe('') + expect(roundTrip(input)).toContain('more body') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx new file mode 100644 index 00000000000..f2e27209ce7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx @@ -0,0 +1,510 @@ +import type { JSONContent, MarkdownToken } from '@tiptap/core' +import { mergeAttributes, Node } from '@tiptap/core' +import type { ReactNodeViewProps } from '@tiptap/react' +import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' + +/** + * Constructs the schema has no node/mark for: raw HTML blocks (`
`, `
`, …), HTML + * comments, and footnotes. Before this file, all four made the *entire* document open read-only + * (see {@link isRoundTripSafe in ./round-trip-safety}) because the stock pipeline silently drops + * or mangles them. Each node below instead holds the exact source text as its content and + * re-emits it byte-for-byte on serialize — the same "hold raw source, re-render specially" shape + * `MarkdownCodeBlock` uses for Mermaid (see `./code-block.tsx`), just without the diagram render. + * + * Inline tags already covered by a real mark/node — `em`/`i`, `strong`/`b`, `s`/`del`/`strike`, + * `code`, `a`, `br`, `img` — are deliberately excluded from {@link RawInlineHtml} so they keep + * parsing into their proper mark (e.g. `x` → italic) instead of freezing as raw source. + */ +const HANDLED_INLINE_TAGS = new Set([ + 'br', + 'img', + 'em', + 'i', + 'strong', + 'b', + 's', + 'del', + 'strike', + 'code', + 'a', +]) + +const VOID_TAGS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +]) + +function verbatimText(node: JSONContent): string { + return (node.content ?? []).map((child) => child.text ?? '').join('') +} + +const RAW_HTML_COMMENT_RE = /^/ + +/** + * One HTML attribute: `name` or `name="value"`/`name='value'`/`name=bare`. The quoted-value + * alternatives are what matter — `[^"]*`/`[^']*` consume a literal `>` inside the quotes as part of + * the value, so an attribute like `data-example="a > b"` is treated as one unit instead of ending + * the tag match at the internal `>`. + */ +const ATTRS_RE_SOURCE = String.raw`(?:\s+[^\s"'=<>\`]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>\`]+))?)*` + +/** Matches one opening HTML tag, attributes included (see {@link ATTRS_RE_SOURCE}). Group 1 is the + * tag name, group 2 is the self-closing `/` if present — shared by inline and block tokenizing. */ +const OPEN_TAG_RE = new RegExp(`^<([a-z][\\w-]*)\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'i') + +/** A fenced block's opening/closing marker may sit inside a blockquote (each line prefixed with + * up to 3 spaces then one or more `>` markers, each optionally followed by a space) and/or be + * independently indented up to 3 spaces with no blockquote at all (CommonMark's own fence-indent + * tolerance — matches `FENCE_OPEN`/`FENCE_CLOSE` in `./markdown-parse.ts`) — matched on both the + * open and close fence line so `> \`\`\`` and ` \`\`\`` both mask correctly. */ +const FENCE_PREFIX_SOURCE = '(?:[ ]{0,3}>[ ]?)*[ ]{0,3}' + +/** Same as {@link RAW_HTML_COMMENT_RE} but not anchored to the start of the string — used by + * {@link maskCodeRegions} to find a comment anywhere in the scanned text, not just at position 0. */ +const HTML_COMMENT_ANYWHERE_RE = //g + +/** + * Mask fenced code blocks, inline code spans, and HTML comments with same-length filler (newlines + * kept, everything else replaced with a space) so a tag-like mention *inside one of these* — + * `` `
` ``, a fenced example showing HTML syntax, or a comment documenting the tag + * (``) — is never mistaken for a real balancing tag while scanning. Mirrors + * the fenced/inline patterns `stripCode` in `./round-trip-safety.ts` matches (extended to also + * tolerate an indented and/or blockquoted fence marker via {@link FENCE_PREFIX_SOURCE}, since a raw + * HTML block can itself be indented or quoted), but preserves length/position (masks in place) + * instead of deleting, so match indices still map onto the original, unmodified `src` the caller + * slices from. + */ +function maskCodeRegions(src: string): string { + const fenceRe = new RegExp( + `^${FENCE_PREFIX_SOURCE}([\`~]{3,})[^\\n]*\\n[\\s\\S]*?^${FENCE_PREFIX_SOURCE}\\1[\`~]*[ \\t]*$`, + 'gm' + ) + return src + .replace(fenceRe, (m) => m.replace(/[^\n]/g, ' ')) + .replace(/`+[^`\n]*`+/g, (m) => ' '.repeat(m.length)) + .replace(HTML_COMMENT_ANYWHERE_RE, (m) => m.replace(/[^\n]/g, ' ')) +} + +/** + * Find the end of the close tag that balances the open tag of `tag` ending at `src[0, fromIndex)`, + * tracking nesting depth from `fromIndex` onward so `outer inner` consumes + * both levels instead of stopping at the first (inner) ``. Returns -1 if unterminated. A + * nested self-closing same-name tag (``) is skipped — it neither opens nor closes a level. + * Shared by the inline tokenizer (single line) and the block tokenizer (spans blank lines). + * + * Scans a {@link maskCodeRegions}-masked copy of `src` so a tag name mentioned inside code doesn't + * count as real markup — this narrows, but can't eliminate, the inherent ambiguity of regex-based + * (non-DOM) tag matching: a *bare, unescaped* mention of the same tag name in plain prose (not in + * code) is indistinguishable from a real closing tag here, exactly as it would be to a real HTML + * parser given the same ambiguous input (there is no valid way to "escape" a literal `` inside + * real HTML content other than an entity or code region). Verified this can't lose data even in that + * case — the result still reaches a stable fixpoint on save, just restructured — matching this file's + * "reject on doubt, but never require doubt-free input" gate (`isRoundTripSafe`). + */ +function findBalancedCloseEnd(src: string, tag: string, fromIndex: number): number { + const masked = maskCodeRegions(src) + const tagRe = new RegExp(`<(/?)${tag}\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'gi') + tagRe.lastIndex = fromIndex + let depth = 1 + for (let match = tagRe.exec(masked); match; match = tagRe.exec(masked)) { + const isClose = match[1] === '/' + const isSelfClosing = Boolean(match[2]) + if (isSelfClosing) continue + if (isClose) { + depth -= 1 + if (depth === 0) return match.index + match[0].length + } else { + depth += 1 + } + } + return -1 +} + +/** + * Marked's own block tokenizer greedily consumes the blank-line run *after* an HTML block/comment + * or a def line as part of that token's own `raw` (the same behavior `PipeSafeTable` in + * `./extensions.ts` works around for tables) — storing it verbatim would double it up with the + * block joiner's own separator, growing by two newlines on every save. Block-level callers trim it; + * inline callers never carry one (inline tokens can't span a blank line), so trimming is a no-op there. + */ +function verbatimParse(raw: string): JSONContent[] { + const trimmed = raw.replace(/\n+$/, '') + return trimmed ? [{ type: 'text', text: trimmed }] : [] +} + +interface VerbatimNodeOptions { + name: string + /** Whether this node sits among block content (own line) or inline content (mid-paragraph). */ + inline: boolean + badgeLabel: string +} + +/** + * Shared shape for a node that holds a markdown construct's exact source text and re-emits it + * unchanged — parsing and rendering never inspect or transform the text, so there is nothing for + * these constructs to lose. `markdownTokenName`/`parseMarkdown`/`renderMarkdown` are read directly + * off the returned config by `@tiptap/markdown`'s `MarkdownManager` (see + * `node_modules/@tiptap/markdown/src/MarkdownManager.ts`), independent of the node's `name`. + */ +function verbatimNodeConfig({ name, inline, badgeLabel }: VerbatimNodeOptions) { + return { + name, + inline, + group: inline ? 'inline' : 'block', + content: 'text*', + marks: '', + code: true, + defining: !inline, + selectable: true, + atom: false, + parseHTML() { + return [ + { + tag: `${inline ? 'span' : 'div'}[data-raw-markdown="${name}"]`, + preserveWhitespace: 'full' as const, + }, + ] + }, + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return [ + inline ? 'span' : 'div', + mergeAttributes(HTMLAttributes, { + 'data-raw-markdown': name, + 'data-raw-markdown-label': badgeLabel, + class: inline ? 'raw-markdown-inline' : 'raw-markdown-block', + }), + 0, + ] as const + }, + renderMarkdown(node: JSONContent) { + return verbatimText(node) + }, + } +} + +/** + * Tag names CommonMark/GFM treat as "block-starting" HTML (marked's own type-6 list — see + * `_tag` in `node_modules/marked/src/rules.ts`, verified against the CommonMark spec): a block + * opening with one of these ends at its *matching closing tag*, not at the first blank line. Tags + * NOT in this list (`em`, `a`, `span`, `code`, `kbd`, …) can legitimately start an ordinary + * paragraph (`hi there`), so they're deliberately left to marked's own stricter, single-line + * block-HTML detection below — claiming them here would risk swallowing a paragraph that merely + * starts with inline HTML. + */ +const BLOCK_HTML_TAG_NAMES = new Set([ + 'address', + 'article', + 'aside', + 'base', + 'basefont', + 'blockquote', + 'body', + 'caption', + 'center', + 'col', + 'colgroup', + 'dd', + 'details', + 'dialog', + 'dir', + 'div', + 'dl', + 'dt', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'frame', + 'frameset', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hr', + 'html', + 'iframe', + 'legend', + 'li', + 'link', + 'main', + 'menu', + 'menuitem', + 'meta', + 'nav', + 'noframes', + 'ol', + 'optgroup', + 'option', + 'p', + 'param', + 'search', + 'section', + 'summary', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'title', + 'tr', + 'track', + 'ul', +]) + +/** + * Marked's built-in block-HTML rule ends a `
`/`
`/… block at the *first blank line* + * (CommonMark's HTML-block-type-6 rule) — correct for normal rendering, but wrong for verbatim + * preservation: any real-world `
` with a paragraph inside would fragment into a raw chip, + * an ordinary (rendered) paragraph, and a second raw chip, stranding genuine content in between. + * This tokenizer instead scans to the tag's *matching* close via {@link findBalancedCloseEnd}, blank + * lines included, for tags in {@link BLOCK_HTML_TAG_NAMES}; anything else returns `undefined` and + * falls through to the existing `markdownTokenName: 'html'` handling below (marked's own block + * tokenizer, unchanged). Comments are matched the same way as the inline case — marked's own comment + * rule already spans blank lines correctly, but routing through one path keeps the two tokenizers + * symmetric and independently testable. CommonMark allows up to 3 leading spaces before a block-HTML + * opening line, so the leading indent is split off, matched against separately, and stitched back + * onto `raw` — everything after that first line (including the tag's own body) can be indented + * however the author wrote it, since the balanced scan doesn't care about column position there. + */ +function tokenizeRawHtmlBlockTag(src: string): MarkdownToken | undefined { + const indent = /^ {0,3}/.exec(src)?.[0] ?? '' + const rest = src.slice(indent.length) + + const comment = RAW_HTML_COMMENT_RE.exec(rest) + if (comment) { + const raw = indent + comment[0] + return { type: 'html', raw, text: raw, block: true } + } + + const open = OPEN_TAG_RE.exec(rest) + if (!open) return undefined + const tag = open[1].toLowerCase() + if (!BLOCK_HTML_TAG_NAMES.has(tag)) return undefined + // A handful of BLOCK_HTML_TAG_NAMES entries (link, meta, base, col, …) are void elements with no + // closing tag at all — treat them as complete right after the open tag (like an explicit `/>`), + // same as `tokenizeRawInlineHtml` already does via VOID_TAGS. Without this, scanning for a + // ``/`` that will never legitimately appear risks grabbing unrelated later content + // (or a stray same-name mention) as if it belonged to this block. + if (open[2] || VOID_TAGS.has(tag)) { + const raw = indent + open[0] + return { type: 'html', raw, text: raw, block: true } + } + + const end = findBalancedCloseEnd(rest, tag, open[0].length) + if (end < 0) return undefined + const raw = indent + rest.slice(0, end) + return { type: 'html', raw, text: raw, block: true } +} + +const SKIP_BLOCK_HTML_TAGS = /^<(img|br)\b[^>]*\/?>\s*$/i + +export const RawHtmlBlock = Node.create({ + ...verbatimNodeConfig({ name: 'rawHtmlBlock', inline: false, badgeLabel: 'Raw HTML' }), + markdownTokenName: 'html', + markdownTokenizer: { + name: 'rawHtmlBlockTag', + level: 'block' as const, + // Always -1 (never claims an early interrupt point): when `start` is omitted, `@tiptap/markdown` + // auto-generates one that calls `this.createLexer()` on every paragraph-continuation check, which + // corrupts the in-progress lexer's shared state (verified directly — every other construct on the + // page silently loses its content once a tokenizer without an explicit `start` is registered). + // The other custom tokenizers below all reference this comment rather than repeating it. + // + // The tokenizer above emits `type: 'html'` explicitly, so its tokens flow into the same + // `markdownTokenName: 'html'` parse registration as marked's own block-HTML tokens below — the + // distinct `name` here only avoids colliding with marked's own built-in `html` extension. + start: () => -1, + tokenize: tokenizeRawHtmlBlockTag, + }, + parseMarkdown(token: MarkdownToken) { + if (!token.block) return [] + const raw = token.raw ?? token.text ?? '' + if (!raw.trim()) return [] + // A lone ``/`
` tag block — leave it to the stock path (Image node / hard break), + // matching the same exclusion `round-trip-safety.ts` used to carve out for these two tags. + if (SKIP_BLOCK_HTML_TAGS.test(raw.trim())) return [] + return { type: 'rawHtmlBlock', content: verbatimParse(raw) } + }, +}) + +const FOOTNOTE_DEF_HEAD_RE = /^ {0,3}\[\^[^\]]+\]:/ +const FOOTNOTE_CONTINUATION_RE = /^ {4,}\S/ + +/** + * Consume a footnote definition's opening line plus any continuation lines GFM allows — indented by + * ≥4 spaces, optionally with blank lines between them (a multi-paragraph definition). Stops at the + * first line that is neither indented nor blank, and never consumes a blank line that isn't followed + * by further continuation (that blank line belongs to whatever block comes next). + */ +function tokenizeFootnoteDef(src: string): MarkdownToken | undefined { + const lines = src.split('\n') + if (!FOOTNOTE_DEF_HEAD_RE.test(lines[0])) return undefined + let lineCount = 1 + while (lineCount < lines.length) { + const line = lines[lineCount] + if (FOOTNOTE_CONTINUATION_RE.test(line)) { + lineCount += 1 + continue + } + if (line === '' && FOOTNOTE_CONTINUATION_RE.test(lines[lineCount + 1] ?? '')) { + lineCount += 2 + continue + } + break + } + const raw = lines.slice(0, lineCount).join('\n') + return { type: 'footnoteDef', raw, text: raw } +} + +/** Footnote definition (`[^id]: the note`, with optional ≥4-space-indented continuation lines) — + * marked has no footnote syntax at all, so without this tokenizer the definition is swallowed as a + * plain paragraph and the reference/definition link is lost. */ +export const FootnoteDef = Node.create({ + ...verbatimNodeConfig({ name: 'footnoteDef', inline: false, badgeLabel: 'Footnote' }), + markdownTokenName: 'footnoteDef', + markdownTokenizer: { + name: 'footnoteDef', + level: 'block' as const, + // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. The cost + // here is narrow and safe: a footnote def sharing a line-run with the preceding paragraph (no + // blank line between them) is picked up on the next block boundary instead of interrupting early. + start: () => -1, + tokenize: tokenizeFootnoteDef, + }, + parseMarkdown(token: MarkdownToken) { + const raw = token.raw ?? token.text ?? '' + if (!raw) return [] + return { type: 'footnoteDef', content: verbatimParse(raw) } + }, +}) + +const FOOTNOTE_REF_RE = /^\[\^[^\]]+\]/ + +/** Footnote reference (`text[^id]`) — verbatim passthrough, same rationale as {@link FootnoteDef}. */ +export const FootnoteRef = Node.create({ + ...verbatimNodeConfig({ name: 'footnoteRef', inline: true, badgeLabel: 'Footnote ref' }), + markdownTokenName: 'footnoteRef', + markdownTokenizer: { + name: 'footnoteRef', + level: 'inline' as const, + // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. + start: () => -1, + tokenize(src: string) { + const match = FOOTNOTE_REF_RE.exec(src) + if (!match) return undefined + return { type: 'footnoteRef', raw: match[0], text: match[0] } + }, + }, + parseMarkdown(token: MarkdownToken) { + const raw = token.raw ?? token.text ?? '' + if (!raw) return [] + return { type: 'footnoteRef', content: verbatimParse(raw) } + }, +}) + +/** + * Attempt to consume an inline HTML comment or a tag (with its matching close tag, or as a single + * void/self-closing element) starting at `src[0]`. Returns `undefined` for a tag this schema + * already has a real mark/node for ({@link HANDLED_INLINE_TAGS}) so it keeps parsing normally, and + * for an unterminated open tag (rare/malformed input — falls back to the stock, lossy behavior + * rather than risk mis-consuming the rest of the document). + */ +function tokenizeRawInlineHtml(src: string): MarkdownToken | undefined { + const comment = RAW_HTML_COMMENT_RE.exec(src) + if (comment) return { type: 'rawInlineHtml', raw: comment[0], text: comment[0] } + + const open = OPEN_TAG_RE.exec(src) + if (!open) return undefined + const tag = open[1].toLowerCase() + if (HANDLED_INLINE_TAGS.has(tag)) return undefined + if (open[2] || VOID_TAGS.has(tag)) { + return { type: 'rawInlineHtml', raw: open[0], text: open[0] } + } + + const end = findBalancedCloseEnd(src, tag, open[0].length) + if (end < 0) return undefined + const raw = src.slice(0, end) + return { type: 'rawInlineHtml', raw, text: raw } +} + +/** Inline raw HTML — ``, ``, ``, ``, `` (no Underline mark is registered), + * and any other tag this schema has no mark/node for, plus an inline-position HTML comment. Marked + * classifies inline HTML as its own `'html'` token type, and `@tiptap/markdown`'s inline parser + * hardcodes handling for that type *before* checking its extension registry (unlike block tokens) — + * so claiming it here needs a custom tokenizer, registered under a different token name + * (`rawInlineHtml`) so it's never confused with the stock `'html'` inline path. marked.js runs + * custom extension tokenizers before its own built-ins at both block and inline level (see + * `blockTokens`/`inlineTokens` in `node_modules/marked/lib/marked.esm.js`), so this reliably wins + * the race against marked's default inline HTML/tag tokenizer. */ +export const RawInlineHtml = Node.create({ + ...verbatimNodeConfig({ name: 'rawInlineHtml', inline: true, badgeLabel: 'Raw HTML' }), + markdownTokenName: 'rawInlineHtml', + markdownTokenizer: { + name: 'rawInlineHtml', + level: 'inline' as const, + // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. + start: () => -1, + tokenize: tokenizeRawInlineHtml, + }, + parseMarkdown(token: MarkdownToken) { + const raw = token.raw ?? token.text ?? '' + if (!raw) return [] + return { type: 'rawInlineHtml', content: verbatimParse(raw) } + }, +}) + +const BLOCK_CONTROL_CLASS = + 'pointer-events-none absolute top-1.5 right-2 select-none rounded-md bg-[var(--surface-4)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] uppercase tracking-wide opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100' + +/** Badge text per block node type name — kept here rather than threaded through node options since + * {@link NodeViewProps} exposes no options/extension reference to the rendering component. */ +const BLOCK_BADGE_LABEL: Record = { + rawHtmlBlock: 'Raw HTML', + footnoteDef: 'Footnote', +} + +function RawBlockView({ node }: ReactNodeViewProps) { + const label = BLOCK_BADGE_LABEL[node.type.name] ?? 'Raw' + return ( + + + {label} + +
+ as='span' /> +
+
+ ) +} + +/** Live variant of {@link RawHtmlBlock} with a hover "Raw HTML" badge — same schema/serializer. */ +export const RawHtmlBlockWithView = RawHtmlBlock.extend({ + addNodeView() { + return ReactNodeViewRenderer(RawBlockView) + }, +}) + +/** Live variant of {@link FootnoteDef} with a hover "Footnote" badge — same schema/serializer. */ +export const FootnoteDefWithView = FootnoteDef.extend({ + addNodeView() { + return ReactNodeViewRenderer(RawBlockView) + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 84d727723b3..8c019c36f7c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -277,6 +277,35 @@ line-height: 21px; } +/* Raw, unrendered markdown constructs the schema has no real node/mark for (raw HTML blocks, + comments, footnotes) — held verbatim and re-emitted byte-for-byte on save (./raw-markdown-snippet.ts). + Same neutral surface as `code`/`pre` below (no color tint — a tint reads as a warning/error state, + which isn't the signal here); the hover "Raw HTML"/"Footnote" badge is what conveys "not interpreted". */ +.rich-markdown-prose .raw-markdown-block, +.rich-markdown-prose .raw-markdown-inline { + font-family: var(--font-martian-mono, ui-monospace, monospace); + font-size: 0.875em; + color: var(--text-muted); + background: var(--surface-5); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.dark .rich-markdown-prose .raw-markdown-block, +.dark .rich-markdown-prose .raw-markdown-inline { + background: var(--code-bg); +} + +.rich-markdown-prose .raw-markdown-block { + border-radius: 8px; + padding: 0.75rem 1rem; +} + +.rich-markdown-prose .raw-markdown-inline { + border-radius: 4px; + padding: 0.0625rem 0.3rem; +} + .rich-markdown-prose hr { border: none; border-top: 1px solid var(--divider); @@ -351,6 +380,15 @@ pointer-events: none; } +/* + * prosemirror-tables' column-resizing plugin toggles the `resize-cursor` class on the editor root + * while the pointer is over a column boundary; without this rule the handle shows but the cursor + * never changes to the resize affordance. + */ +.rich-markdown-prose.resize-cursor { + cursor: col-resize; +} + .rich-markdown-prose p.is-editor-empty:first-child::before { content: attr(data-placeholder); color: var(--text-subtle); diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 52f8982c1bd..d6fc15224a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -24,6 +24,7 @@ import { parseMarkdownToDoc } from './markdown-parse' import { useEditorMentions } from './mention' import { EditorBubbleMenu } from './menus/bubble-menu' import { LinkHoverCard } from './menus/link-hover-card' +import { TableBubbleMenu } from './menus/table-menu' import { normalizeMarkdownContent } from './normalize-content' import { isRoundTripSafe } from './round-trip-safety' import '@sim/emcn/components/code/code.css' @@ -249,6 +250,7 @@ export function LoadedRichMarkdownEditor({ const editor = useEditor({ extensions: EXTENSIONS, editable: isEditable, + enablePasteRules: false, autofocus: streamingAtMountRef.current ? false : autoFocus ? 'end' : false, immediatelyRender: false, shouldRerenderOnTransaction: false, @@ -413,6 +415,15 @@ export function LoadedRichMarkdownEditor({ emitUpdate: false, }) } + // `setContent` maps any pre-existing selection onto the new doc rather than clearing it — a + // select-all survives as "select everything," permanently painting every divider/image with the + // `rich-leaf-in-selection` decoration (keymap.ts) until the user clicks elsewhere. This must run + // on every settle regardless of whether `setContent` ran just above: the last streaming tick + // already syncs `lastSyncedBodyRef` to the final body before settle, so `body` usually already + // equals it here — collapsing only inside that `if` would skip the common streamed-content case + // entirely. `setTextSelection` (not `.focus()`) so this never steals DOM focus from whatever the + // user is doing outside the editor. + editor.commands.setTextSelection(editor.state.doc.content.size) editor.setEditable(canEdit && settledRef.current.verdict) if (isInitialSettle && autoFocus) editor.commands.focus('end') return @@ -433,6 +444,7 @@ export function LoadedRichMarkdownEditor({ className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')} > {editor && } + {editor && } {editor && } { expect(isRoundTripSafe('> ```\n> code\n> ```')).toBe(true) }) - it('rejects stable-loss constructs the idempotency probe cannot see', () => { - expect(isRoundTripSafe('text[^1]\n\n[^1]: the note')).toBe(false) - expect(isRoundTripSafe('\n\ntext')).toBe(false) - expect(isRoundTripSafe('
xbody
')).toBe(false) - expect(isRoundTripSafe('a b c')).toBe(false) + it('preserves footnotes, HTML comments, and raw HTML tags via the verbatim snippet nodes', () => { + expect(isRoundTripSafe('text[^1]\n\n[^1]: the note')).toBe(true) + expect(isRoundTripSafe('\n\ntext')).toBe(true) + expect(isRoundTripSafe('
xbody
')).toBe(true) + expect(isRoundTripSafe('a b c')).toBe(true) }) it('rejects a hard break inside a heading (serializer splits the heading)', () => { @@ -263,15 +263,19 @@ describe('editability gate — realistic documents stay editable', () => { }) // The flip side and exact boundary of the gate: constructs the WYSIWYG schema genuinely cannot -// represent open read-only so an edit can't silently corrupt them. +// represent open read-only so an edit can't silently corrupt them. Raw HTML blocks, comments, and +// footnotes used to be the canonical examples here — `./raw-markdown-snippet.ts` now holds each +// verbatim (including a multi-line block spanning blank lines, via the same `NON_CHUNKABLE` +// whole-document parse path `markdown-parse.ts` already uses for these constructs), so they moved +// to the "preserved" test above instead of staying here. describe('editability gate — genuinely lossy constructs open read-only', () => { - it('raw HTML blocks (
,
) open read-only', () => { - expect(isRoundTripSafe('
More\n\nbody\n\n
')).toBe(false) - expect(isRoundTripSafe('
\n\ncentered\n\n
')).toBe(false) + it('raw HTML blocks (
,
) are preserved verbatim, not locked read-only', () => { + expect(isRoundTripSafe('
More\n\nbody\n\n
')).toBe(true) + expect(isRoundTripSafe('
\n\ncentered\n\n
')).toBe(true) }) - it('HTML comments and footnotes open read-only', () => { - expect(isRoundTripSafe('\n\ntext')).toBe(false) - expect(isRoundTripSafe('a claim[^1]\n\n[^1]: the source')).toBe(false) + it('HTML comments and footnotes are preserved verbatim, not locked read-only', () => { + expect(isRoundTripSafe('\n\ntext')).toBe(true) + expect(isRoundTripSafe('a claim[^1]\n\n[^1]: the source')).toBe(true) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts index a3d473b3193..423669de7c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts @@ -15,12 +15,11 @@ const PROBE_SIZE_LIMIT = 256 * 1024 * (Linked images `[![alt](img)](href)` are handled by the image node and verified separately by * the link-count check in {@link isRoundTripSafe}, not here.) * - * - **Footnote** `[^id]` — not in the schema; the reference and definition serialize to escaped - * literal text, breaking the footnote. - * - **HTML comment** `` — dropped entirely. - * - **Raw HTML tag** `
`, `
`, ``, … — StarterKit has no HTML node, so the tag - * is stripped (content kept, structure lost). `
` and `` are excluded: `
` outside a - * table converts to a hard break, and `` is a first-class (resizable) image node. + * Footnotes, HTML comments, and raw HTML tags (`
`, `
`, ``, …) used to be listed + * here — the schema had no node for any of them, so they were dropped or stripped (content kept, + * structure lost). `./raw-markdown-snippet.ts` now holds each construct's exact source text and + * re-emits it byte-for-byte, so none of them lose data on round-trip and none need a pattern below. + * * - **`
` inside a table cell** — a GFM cell can't hold a real line break, so the serializer * flattens `one
two` to `one two`. Matched on a table-shaped line (≥2 pipes) containing a `
`. * - **Hard break inside a heading** (trailing two spaces or a backslash) — the serializer splits @@ -30,9 +29,6 @@ const PROBE_SIZE_LIMIT = 256 * 1024 * `&` with no `;` is left alone (it re-renders identically, so it's harmless churn). */ const STABLE_LOSS_PATTERNS: ReadonlyArray = [ - /\[\^[^\]]+]/, - / statement-breakpoint +-- migration-safe: credential_set_member readers removed with the credential-sets API/fan-out in this change; prod has 0 rows +DROP TABLE "credential_set_member" CASCADE;--> statement-breakpoint +-- migration-safe: credential_set readers removed in this change; CASCADE also drops the webhook.credential_set_id FK constraint; prod has 1 orphaned row with no members, invitations, or webhooks +DROP TABLE "credential_set" CASCADE;--> statement-breakpoint +-- migration-safe: webhook.credential_set_id readers/writers (deploy fan-out, polling plan gate, processor billing gate, webhook routes) all removed in this change; prod verified at 0 non-null values; drops webhook_credential_set_id_idx with it +ALTER TABLE "webhook" DROP COLUMN "credential_set_id";--> statement-breakpoint +-- migration-safe: credential_set_invitation_status enum is only referenced by the credential_set_invitation table dropped above +DROP TYPE "public"."credential_set_invitation_status";--> statement-breakpoint +-- migration-safe: credential_set_member_status enum is only referenced by the credential_set_member table dropped above +DROP TYPE "public"."credential_set_member_status"; diff --git a/packages/db/migrations/meta/0255_snapshot.json b/packages/db/migrations/meta/0255_snapshot.json new file mode 100644 index 00000000000..11e2a922f43 --- /dev/null +++ b/packages/db/migrations/meta/0255_snapshot.json @@ -0,0 +1,16681 @@ +{ + "id": "89ca1946-db4f-4726-b15f-b7869d9bcf41", + "prevId": "1439d1e8-9a5e-4002-a68a-a9bbdd22290f", + "version": "7", + "dialect": "postgresql", + "tables": { + "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": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "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_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "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.background_work_status": { + "name": "background_work_status", + "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": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_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": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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 + }, + "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 + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "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_messages": { + "name": "copilot_messages", + "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 + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "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": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "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_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "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.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_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.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "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 + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_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": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_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": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "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 + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_by": { + "name": "uploaded_by", + "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_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "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" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "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.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "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": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "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.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": "cascade", + "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_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_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_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_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 + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "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.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_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 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_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": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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 + }, + "next_resume_at": { + "name": "next_resume_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": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "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 + }, + "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 + }, + "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()" + }, + "is_default": { + "name": "is_default", + "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_organization_name_unique": { + "name": "permission_group_organization_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": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_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 + }, + "organization_id": { + "name": "organization_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_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_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.permission_group_workspace": { + "name": "permission_group_workspace", + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": "'[]'" + }, + "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": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "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 + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "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 + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "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 + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "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.table_jobs": { + "name": "table_jobs", + "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 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_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": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "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 + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_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 + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "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": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "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": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "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_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 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "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 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "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 + }, + "order_key": { + "name": "order_key", + "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()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": 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": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "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 + }, + "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": {} + }, + "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_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": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "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": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "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" + } + }, + "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 + }, + "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 + }, + "locked": { + "name": "locked", + "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 + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "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_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "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 + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "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 + }, + "infra_retry_count": { + "name": "infra_retry_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 + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_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": { + "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": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "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 + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "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_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": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "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" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": 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": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_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_file_folders": { + "name": "workspace_file_folders", + "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 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_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": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_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": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_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_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "folder_id": { + "name": "folder_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 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_folder_name_active_unique": { + "name": "workspace_files_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": "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_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_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_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_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_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "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_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "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_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "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()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": 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": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "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_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "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.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "custom_tool", + "skill" + ] + }, + "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 6138ec0120b..f9320ed41b7 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1779,6 +1779,13 @@ "when": 1782957781005, "tag": "0254_custom_block", "breakpoints": true + }, + { + "idx": 255, + "version": "7", + "when": 1783382202081, + "tag": "0255_remove_credential_sets", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index bd6f978daa0..122b987e336 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -744,9 +744,6 @@ export const webhook = pgTable( isActive: boolean('is_active').notNull().default(true), failedCount: integer('failed_count').default(0), // Track consecutive failures lastFailedAt: timestamp('last_failed_at'), // When the webhook last failed - credentialSetId: text('credential_set_id').references(() => credentialSet.id, { - onDelete: 'set null', - }), // For credential set webhooks - enables efficient queries archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), @@ -761,8 +758,6 @@ export const webhook = pgTable( table.workflowId, table.deploymentVersionId ), - // Optimize queries for credential set webhooks - credentialSetIdIdx: index('webhook_credential_set_id_idx').on(table.credentialSetId), archivedAtPartialIdx: index('webhook_archived_at_partial_idx') .on(table.archivedAt) .where(sql`${table.archivedAt} IS NOT NULL`), @@ -3107,99 +3102,6 @@ export const pendingCredentialDraft = pgTable( }) ) -export const credentialSet = pgTable( - 'credential_set', - { - id: text('id').primaryKey(), - organizationId: text('organization_id') - .notNull() - .references(() => organization.id, { onDelete: 'cascade' }), - name: text('name').notNull(), - description: text('description'), - providerId: text('provider_id').notNull(), - createdBy: text('created_by') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), - }, - (table) => ({ - createdByIdx: index('credential_set_created_by_idx').on(table.createdBy), - orgNameUnique: uniqueIndex('credential_set_org_name_unique').on( - table.organizationId, - table.name - ), - providerIdIdx: index('credential_set_provider_id_idx').on(table.providerId), - }) -) - -export const credentialSetMemberStatusEnum = pgEnum('credential_set_member_status', [ - 'active', - 'pending', - 'revoked', -]) - -export const credentialSetMember = pgTable( - 'credential_set_member', - { - id: text('id').primaryKey(), - credentialSetId: text('credential_set_id') - .notNull() - .references(() => credentialSet.id, { onDelete: 'cascade' }), - userId: text('user_id') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - status: credentialSetMemberStatusEnum('status').notNull().default('pending'), - joinedAt: timestamp('joined_at'), - invitedBy: text('invited_by').references(() => user.id, { onDelete: 'set null' }), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), - }, - (table) => ({ - userIdIdx: index('credential_set_member_user_id_idx').on(table.userId), - uniqueMembership: uniqueIndex('credential_set_member_unique').on( - table.credentialSetId, - table.userId - ), - statusIdx: index('credential_set_member_status_idx').on(table.status), - }) -) - -export const credentialSetInvitationStatusEnum = pgEnum('credential_set_invitation_status', [ - 'pending', - 'accepted', - 'expired', - 'cancelled', -]) - -export const credentialSetInvitation = pgTable( - 'credential_set_invitation', - { - id: text('id').primaryKey(), - credentialSetId: text('credential_set_id') - .notNull() - .references(() => credentialSet.id, { onDelete: 'cascade' }), - email: text('email'), - token: text('token').notNull().unique(), - invitedBy: text('invited_by') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - status: credentialSetInvitationStatusEnum('status').notNull().default('pending'), - expiresAt: timestamp('expires_at').notNull(), - acceptedAt: timestamp('accepted_at'), - acceptedByUserId: text('accepted_by_user_id').references(() => user.id, { - onDelete: 'set null', - }), - createdAt: timestamp('created_at').notNull().defaultNow(), - }, - (table) => ({ - credentialSetIdIdx: index('credential_set_invitation_set_id_idx').on(table.credentialSetId), - tokenIdx: index('credential_set_invitation_token_idx').on(table.token), - statusIdx: index('credential_set_invitation_status_idx').on(table.status), - expiresAtIdx: index('credential_set_invitation_expires_at_idx').on(table.expiresAt), - }) -) - /** * A named set of access-control restrictions (`config`) governing users within * an organization. diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index d7a16b82364..82a59734b8f 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -47,15 +47,6 @@ export const auditMock = { CREDENTIAL_MEMBER_REMOVED: 'credential_member.removed', CREDENTIAL_MEMBER_ROLE_CHANGED: 'credential_member.role_changed', CREDIT_PURCHASED: 'credit.purchased', - CREDENTIAL_SET_CREATED: 'credential_set.created', - CREDENTIAL_SET_UPDATED: 'credential_set.updated', - CREDENTIAL_SET_DELETED: 'credential_set.deleted', - CREDENTIAL_SET_MEMBER_REMOVED: 'credential_set_member.removed', - CREDENTIAL_SET_MEMBER_LEFT: 'credential_set_member.left', - CREDENTIAL_SET_INVITATION_CREATED: 'credential_set_invitation.created', - CREDENTIAL_SET_INVITATION_ACCEPTED: 'credential_set_invitation.accepted', - CREDENTIAL_SET_INVITATION_RESENT: 'credential_set_invitation.resent', - CREDENTIAL_SET_INVITATION_REVOKED: 'credential_set_invitation.revoked', CUSTOM_TOOL_CREATED: 'custom_tool.created', CUSTOM_TOOL_UPDATED: 'custom_tool.updated', CUSTOM_TOOL_DELETED: 'custom_tool.deleted', @@ -165,7 +156,6 @@ export const auditMock = { CHAT: 'chat', CONNECTOR: 'connector', CREDENTIAL: 'credential', - CREDENTIAL_SET: 'credential_set', CUSTOM_TOOL: 'custom_tool', DATA_DRAIN: 'data_drain', DOCUMENT: 'document', diff --git a/packages/testing/src/mocks/auth-oauth-utils.mock.ts b/packages/testing/src/mocks/auth-oauth-utils.mock.ts index ad0d6395ac1..a11162377c6 100644 --- a/packages/testing/src/mocks/auth-oauth-utils.mock.ts +++ b/packages/testing/src/mocks/auth-oauth-utils.mock.ts @@ -35,7 +35,6 @@ export const authOAuthUtilsMockFns = { mockGetOAuthToken: vi.fn(), mockRefreshAccessTokenIfNeeded: vi.fn(), mockRefreshTokenIfNeeded: vi.fn(), - mockGetCredentialsForCredentialSet: vi.fn(), } /** @@ -55,5 +54,4 @@ export const authOAuthUtilsMock = { getOAuthToken: authOAuthUtilsMockFns.mockGetOAuthToken, refreshAccessTokenIfNeeded: authOAuthUtilsMockFns.mockRefreshAccessTokenIfNeeded, refreshTokenIfNeeded: authOAuthUtilsMockFns.mockRefreshTokenIfNeeded, - getCredentialsForCredentialSet: authOAuthUtilsMockFns.mockGetCredentialsForCredentialSet, } diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 597d572090a..480ae0d62f0 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -23,7 +23,6 @@ export const envFlagsMock = { isTriggerDevEnabled: false, isTablesFractionalOrderingEnabled: false, isSsoEnabled: false, - isCredentialSetsEnabled: false, isAccessControlEnabled: false, isOrganizationsEnabled: false, isInboxEnabled: false, diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index aa28b59fe36..4d52742a3a5 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -302,7 +302,6 @@ export const schemaMock = { isActive: 'isActive', failedCount: 'failedCount', lastFailedAt: 'lastFailedAt', - credentialSetId: 'credentialSetId', archivedAt: 'archivedAt', createdAt: 'createdAt', updatedAt: 'updatedAt', @@ -939,40 +938,6 @@ export const schemaMock = { expiresAt: 'expiresAt', createdAt: 'createdAt', }, - credentialSet: { - id: 'id', - organizationId: 'organizationId', - name: 'name', - description: 'description', - providerId: 'providerId', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - }, - credentialSetMemberStatusEnum: 'credentialSetMemberStatusEnum', - credentialSetMember: { - id: 'id', - credentialSetId: 'credentialSetId', - userId: 'userId', - status: 'status', - joinedAt: 'joinedAt', - invitedBy: 'invitedBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - }, - credentialSetInvitationStatusEnum: 'credentialSetInvitationStatusEnum', - credentialSetInvitation: { - id: 'id', - credentialSetId: 'credentialSetId', - email: 'email', - token: 'token', - invitedBy: 'invitedBy', - status: 'status', - expiresAt: 'expiresAt', - acceptedAt: 'acceptedAt', - acceptedByUserId: 'acceptedByUserId', - createdAt: 'createdAt', - }, permissionGroup: { id: 'id', organizationId: 'organizationId', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0135f8596d5..334f8ff2170 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 887, - zodRoutes: 887, + totalRoutes: 906, + zodRoutes: 906, nonZodRoutes: 0, } as const @@ -45,7 +45,6 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/auth/oauth/connections/route.ts', 'apps/sim/app/api/auth/providers/route.ts', 'apps/sim/app/api/auth/socket-token/route.ts', - 'apps/sim/app/api/credential-sets/invitations/route.ts', 'apps/sim/app/api/workspaces/invitations/route.ts', // Internal cron entry point that authenticates via `Authorization: Bearer // CRON_SECRET` and ignores query/body. The boundary contract is "no