mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 05:18:08 -05:00
Compare commits
152 Commits
feat/copil
...
lakees/db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03d1f5d58 | ||
|
|
be757a4f1e | ||
|
|
1938818027 | ||
|
|
2818b745d1 | ||
|
|
2d49de76ea | ||
|
|
1f682eb343 | ||
|
|
8d43947eb5 | ||
|
|
107679bf41 | ||
|
|
103b31a569 | ||
|
|
004e058353 | ||
|
|
5157f0bbb2 | ||
|
|
8bbcf31b83 | ||
|
|
9e814315dd | ||
|
|
0ea0256623 | ||
|
|
fb8868c854 | ||
|
|
ea4964052d | ||
|
|
268e2f114f | ||
|
|
5988d0e46f | ||
|
|
145db9d8c3 | ||
|
|
294b168ed9 | ||
|
|
0dc2c1fe0d | ||
|
|
fb90c4e9b1 | ||
|
|
0af96d06c6 | ||
|
|
1d450578c8 | ||
|
|
c6d408c65b | ||
|
|
16716ea26a | ||
|
|
563098ca0a | ||
|
|
1f1f015031 | ||
|
|
4afb245fa2 | ||
|
|
8344d68ca8 | ||
|
|
a26a1a9737 | ||
|
|
689037a300 | ||
|
|
07f0c01dc4 | ||
|
|
e4ad31bb6b | ||
|
|
84691fc873 | ||
|
|
2daf34386e | ||
|
|
ac991d4b54 | ||
|
|
a8e413a999 | ||
|
|
f05f5bbc6d | ||
|
|
87f8fcdbf2 | ||
|
|
6e8dc771fe | ||
|
|
d0c3c6aec7 | ||
|
|
8574d66aac | ||
|
|
e79e9e7367 | ||
|
|
a8bb0db660 | ||
|
|
4b6de03a62 | ||
|
|
37b50cbce6 | ||
|
|
7ca628db13 | ||
|
|
118e4f65f0 | ||
|
|
292cd39cfb | ||
|
|
ea77790484 | ||
|
|
895591514a | ||
|
|
0e1133fc42 | ||
|
|
4357230a9d | ||
|
|
e7f45166af | ||
|
|
c662a31ac8 | ||
|
|
51d1b958e2 | ||
|
|
3d81c1cc14 | ||
|
|
94c6795efc | ||
|
|
86c5e1b4ff | ||
|
|
cca1772ae1 | ||
|
|
e4dd14df7a | ||
|
|
448b8f056c | ||
|
|
abb671e61b | ||
|
|
f90c9c7593 | ||
|
|
2e624c20b5 | ||
|
|
7093209bce | ||
|
|
897891ee1e | ||
|
|
42aa794713 | ||
|
|
ea72ab5aa9 | ||
|
|
5173320bb5 | ||
|
|
26d96624af | ||
|
|
271375df9b | ||
|
|
a940dd6351 | ||
|
|
e69500726b | ||
|
|
c94bb5acda | ||
|
|
fef2d2cc82 | ||
|
|
44909964b7 | ||
|
|
1a13762617 | ||
|
|
cfffd050a2 | ||
|
|
d00997c5ea | ||
|
|
466559578e | ||
|
|
0a6312dbac | ||
|
|
e503408825 | ||
|
|
ed543a71f9 | ||
|
|
7f894ec023 | ||
|
|
57fbd2aa1c | ||
|
|
80270ce7b2 | ||
|
|
fdc3af994c | ||
|
|
5a69d16e65 | ||
|
|
c3afbaebce | ||
|
|
793c888808 | ||
|
|
ffad20efc5 | ||
|
|
b08ce03409 | ||
|
|
c9373c7b3e | ||
|
|
cbb93c65b6 | ||
|
|
96a3fe59ff | ||
|
|
df3e869f22 | ||
|
|
b3ca0c947c | ||
|
|
cfbc8d7211 | ||
|
|
15bef489f2 | ||
|
|
4422a69a17 | ||
|
|
8f9cf93231 | ||
|
|
22f89cf67d | ||
|
|
dfa018f2d4 | ||
|
|
e287388b03 | ||
|
|
4d176c0717 | ||
|
|
c155d8ac6c | ||
|
|
48250f5ed8 | ||
|
|
fc6dbcf066 | ||
|
|
a537ca7ebe | ||
|
|
c1eef30578 | ||
|
|
6605c887ed | ||
|
|
a919816bff | ||
|
|
8a8589e18d | ||
|
|
ed807bebf2 | ||
|
|
48ecb19af7 | ||
|
|
9a3d5631f2 | ||
|
|
0872314fbf | ||
|
|
7e4fc32d82 | ||
|
|
4316f45175 | ||
|
|
e80660f218 | ||
|
|
5dddb03eac | ||
|
|
6386e6b437 | ||
|
|
af82820a28 | ||
|
|
4372841797 | ||
|
|
5e8c843241 | ||
|
|
7bf3d73ee6 | ||
|
|
7ffc11a738 | ||
|
|
be578e2ed7 | ||
|
|
f415e5edc4 | ||
|
|
13a6e6c3fa | ||
|
|
f5ab7f21ae | ||
|
|
bfb6fffe38 | ||
|
|
4fbec0a43f | ||
|
|
585f5e365b | ||
|
|
3792bdd252 | ||
|
|
eb5d1f3e5b | ||
|
|
54ab82c8dd | ||
|
|
f895bf469b | ||
|
|
dd3209af06 | ||
|
|
b6ba3b50a7 | ||
|
|
b304233062 | ||
|
|
57e4b49bd6 | ||
|
|
e12dd204ed | ||
|
|
3d9d9cbc54 | ||
|
|
0f4ec962ad | ||
|
|
4827866f9a | ||
|
|
3e697d9ed9 | ||
|
|
4431a1a484 | ||
|
|
4d1a9a3f22 | ||
|
|
eb07a080fb |
35
.claude/rules/emcn-components.md
Normal file
35
.claude/rules/emcn-components.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/components/emcn/**"
|
||||
---
|
||||
|
||||
# EMCN Components
|
||||
|
||||
Import from `@/components/emcn`, never from subpaths (except CSS files).
|
||||
|
||||
## CVA vs Direct Styles
|
||||
|
||||
**Use CVA when:** 2+ variants (primary/secondary, sm/md/lg)
|
||||
|
||||
```tsx
|
||||
const buttonVariants = cva('base-classes', {
|
||||
variants: { variant: { default: '...', primary: '...' } }
|
||||
})
|
||||
export { Button, buttonVariants }
|
||||
```
|
||||
|
||||
**Use direct className when:** Single consistent style, no variations
|
||||
|
||||
```tsx
|
||||
function Label({ className, ...props }) {
|
||||
return <Primitive className={cn('style-classes', className)} {...props} />
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Use Radix UI primitives for accessibility
|
||||
- Export component and variants (if using CVA)
|
||||
- TSDoc with usage examples
|
||||
- Consistent tokens: `font-medium`, `text-[12px]`, `rounded-[4px]`
|
||||
- `transition-colors` for hover states
|
||||
13
.claude/rules/global.md
Normal file
13
.claude/rules/global.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Global Standards
|
||||
|
||||
## Logging
|
||||
Import `createLogger` from `sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
|
||||
|
||||
## Comments
|
||||
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
||||
|
||||
## Styling
|
||||
Never update global styles. Keep all styling local to components.
|
||||
|
||||
## Package Manager
|
||||
Use `bun` and `bunx`, not `npm` and `npx`.
|
||||
56
.claude/rules/sim-architecture.md
Normal file
56
.claude/rules/sim-architecture.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**"
|
||||
---
|
||||
|
||||
# Sim App Architecture
|
||||
|
||||
## Core Principles
|
||||
1. **Single Responsibility**: Each component, hook, store has one clear purpose
|
||||
2. **Composition Over Complexity**: Break down complex logic into smaller pieces
|
||||
3. **Type Safety First**: TypeScript interfaces for all props, state, return types
|
||||
4. **Predictable State**: Zustand for global state, useState for UI-only concerns
|
||||
|
||||
## Root-Level Structure
|
||||
|
||||
```
|
||||
apps/sim/
|
||||
├── app/ # Next.js app router (pages, API routes)
|
||||
├── blocks/ # Block definitions and registry
|
||||
├── components/ # Shared UI (emcn/, ui/)
|
||||
├── executor/ # Workflow execution engine
|
||||
├── hooks/ # Shared hooks (queries/, selectors/)
|
||||
├── lib/ # App-wide utilities
|
||||
├── providers/ # LLM provider integrations
|
||||
├── stores/ # Zustand stores
|
||||
├── tools/ # Tool definitions
|
||||
└── triggers/ # Trigger definitions
|
||||
```
|
||||
|
||||
## Feature Organization
|
||||
|
||||
Features live under `app/workspace/[workspaceId]/`:
|
||||
|
||||
```
|
||||
feature/
|
||||
├── components/ # Feature components
|
||||
├── hooks/ # Feature-scoped hooks
|
||||
├── utils/ # Feature-scoped utilities (2+ consumers)
|
||||
├── feature.tsx # Main component
|
||||
└── page.tsx # Next.js page entry
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
- **Components**: PascalCase (`WorkflowList`)
|
||||
- **Hooks**: `use` prefix (`useWorkflowOperations`)
|
||||
- **Files**: kebab-case (`workflow-list.tsx`)
|
||||
- **Stores**: `stores/feature/store.ts`
|
||||
- **Constants**: SCREAMING_SNAKE_CASE
|
||||
- **Interfaces**: PascalCase with suffix (`WorkflowListProps`)
|
||||
|
||||
## Utils Rules
|
||||
|
||||
- **Never create `utils.ts` for single consumer** - inline it
|
||||
- **Create `utils.ts` when** 2+ files need the same helper
|
||||
- **Check existing sources** before duplicating (`lib/` has many utilities)
|
||||
- **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use)
|
||||
48
.claude/rules/sim-components.md
Normal file
48
.claude/rules/sim-components.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.tsx"
|
||||
---
|
||||
|
||||
# Component Patterns
|
||||
|
||||
## Structure Order
|
||||
|
||||
```typescript
|
||||
'use client' // Only if using hooks
|
||||
|
||||
// Imports (external → internal)
|
||||
// Constants at module level
|
||||
const CONFIG = { SPACING: 8 } as const
|
||||
|
||||
// Props interface
|
||||
interface ComponentProps {
|
||||
requiredProp: string
|
||||
optionalProp?: boolean
|
||||
}
|
||||
|
||||
export function Component({ requiredProp, optionalProp = false }: ComponentProps) {
|
||||
// a. Refs
|
||||
// b. External hooks (useParams, useRouter)
|
||||
// c. Store hooks
|
||||
// d. Custom hooks
|
||||
// e. Local state
|
||||
// f. useMemo
|
||||
// g. useCallback
|
||||
// h. useEffect
|
||||
// i. Return JSX
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. `'use client'` only when using React hooks
|
||||
2. Always define props interface
|
||||
3. Extract constants with `as const`
|
||||
4. Semantic HTML (`aside`, `nav`, `article`)
|
||||
5. Optional chain callbacks: `onAction?.(id)`
|
||||
|
||||
## Component Extraction
|
||||
|
||||
**Extract when:** 50+ lines, used in 2+ files, or has own state/logic
|
||||
|
||||
**Keep inline when:** < 10 lines, single use, purely presentational
|
||||
55
.claude/rules/sim-hooks.md
Normal file
55
.claude/rules/sim-hooks.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/use-*.ts"
|
||||
- "apps/sim/**/hooks/**/*.ts"
|
||||
---
|
||||
|
||||
# Hook Patterns
|
||||
|
||||
## Structure
|
||||
|
||||
```typescript
|
||||
interface UseFeatureProps {
|
||||
id: string
|
||||
onSuccess?: (result: Result) => void
|
||||
}
|
||||
|
||||
export function useFeature({ id, onSuccess }: UseFeatureProps) {
|
||||
// 1. Refs for stable dependencies
|
||||
const idRef = useRef(id)
|
||||
const onSuccessRef = useRef(onSuccess)
|
||||
|
||||
// 2. State
|
||||
const [data, setData] = useState<Data | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// 3. Sync refs
|
||||
useEffect(() => {
|
||||
idRef.current = id
|
||||
onSuccessRef.current = onSuccess
|
||||
}, [id, onSuccess])
|
||||
|
||||
// 4. Operations (useCallback with empty deps when using refs)
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await fetch(`/api/${idRef.current}`).then(r => r.json())
|
||||
setData(result)
|
||||
onSuccessRef.current?.(result)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { data, isLoading, fetchData }
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. Single responsibility per hook
|
||||
2. Props interface required
|
||||
3. Refs for stable callback dependencies
|
||||
4. Wrap returned functions in useCallback
|
||||
5. Always try/catch async operations
|
||||
6. Track loading/error states
|
||||
62
.claude/rules/sim-imports.md
Normal file
62
.claude/rules/sim-imports.md
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.ts"
|
||||
- "apps/sim/**/*.tsx"
|
||||
---
|
||||
|
||||
# Import Patterns
|
||||
|
||||
## Absolute Imports
|
||||
|
||||
**Always use absolute imports.** Never use relative imports.
|
||||
|
||||
```typescript
|
||||
// ✓ Good
|
||||
import { useWorkflowStore } from '@/stores/workflows/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// ✗ Bad
|
||||
import { useWorkflowStore } from '../../../stores/workflows/store'
|
||||
```
|
||||
|
||||
## Barrel Exports
|
||||
|
||||
Use barrel exports (`index.ts`) when a folder has 3+ exports. Import from barrel, not individual files.
|
||||
|
||||
```typescript
|
||||
// ✓ Good
|
||||
import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/components'
|
||||
|
||||
// ✗ Bad
|
||||
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard'
|
||||
```
|
||||
|
||||
## No Re-exports
|
||||
|
||||
Do not re-export from non-barrel files. Import directly from the source.
|
||||
|
||||
```typescript
|
||||
// ✓ Good - import from where it's declared
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
|
||||
// ✗ Bad - re-exporting in utils.ts then importing from there
|
||||
import { CORE_TRIGGER_TYPES } from '@/app/workspace/.../utils'
|
||||
```
|
||||
|
||||
## Import Order
|
||||
|
||||
1. React/core libraries
|
||||
2. External libraries
|
||||
3. UI components (`@/components/emcn`, `@/components/ui`)
|
||||
4. Utilities (`@/lib/...`)
|
||||
5. Stores (`@/stores/...`)
|
||||
6. Feature imports
|
||||
7. CSS imports
|
||||
|
||||
## Type Imports
|
||||
|
||||
Use `type` keyword for type-only imports:
|
||||
|
||||
```typescript
|
||||
import type { WorkflowLog } from '@/stores/logs/types'
|
||||
```
|
||||
209
.claude/rules/sim-integrations.md
Normal file
209
.claude/rules/sim-integrations.md
Normal file
@@ -0,0 +1,209 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/tools/**"
|
||||
- "apps/sim/blocks/**"
|
||||
- "apps/sim/triggers/**"
|
||||
---
|
||||
|
||||
# Adding Integrations
|
||||
|
||||
## Overview
|
||||
|
||||
Adding a new integration typically requires:
|
||||
1. **Tools** - API operations (`tools/{service}/`)
|
||||
2. **Block** - UI component (`blocks/blocks/{service}.ts`)
|
||||
3. **Icon** - SVG icon (`components/icons.tsx`)
|
||||
4. **Trigger** (optional) - Webhooks/polling (`triggers/{service}/`)
|
||||
|
||||
Always look up the service's API docs first.
|
||||
|
||||
## 1. Tools (`tools/{service}/`)
|
||||
|
||||
```
|
||||
tools/{service}/
|
||||
├── index.ts # Export all tools
|
||||
├── types.ts # Params/response types
|
||||
├── {action}.ts # Individual tool (e.g., send_message.ts)
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Tool file structure:**
|
||||
|
||||
```typescript
|
||||
// tools/{service}/{action}.ts
|
||||
import type { {Service}Params, {Service}Response } from '@/tools/{service}/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const {service}{Action}Tool: ToolConfig<{Service}Params, {Service}Response> = {
|
||||
id: '{service}_{action}',
|
||||
name: '{Service} {Action}',
|
||||
description: 'What this tool does',
|
||||
version: '1.0.0',
|
||||
oauth: { required: true, provider: '{service}' }, // if OAuth
|
||||
params: { /* param definitions */ },
|
||||
request: {
|
||||
url: '/api/tools/{service}/{action}',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({ ...params }),
|
||||
},
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!data.success) throw new Error(data.error)
|
||||
return { success: true, output: data.output }
|
||||
},
|
||||
outputs: { /* output definitions */ },
|
||||
}
|
||||
```
|
||||
|
||||
**Register in `tools/registry.ts`:**
|
||||
|
||||
```typescript
|
||||
import { {service}{Action}Tool } from '@/tools/{service}'
|
||||
// Add to registry object
|
||||
{service}_{action}: {service}{Action}Tool,
|
||||
```
|
||||
|
||||
## 2. Block (`blocks/blocks/{service}.ts`)
|
||||
|
||||
```typescript
|
||||
import { {Service}Icon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { {Service}Response } from '@/tools/{service}/types'
|
||||
|
||||
export const {Service}Block: BlockConfig<{Service}Response> = {
|
||||
type: '{service}',
|
||||
name: '{Service}',
|
||||
description: 'Short description',
|
||||
longDescription: 'Detailed description',
|
||||
category: 'tools',
|
||||
bgColor: '#hexcolor',
|
||||
icon: {Service}Icon,
|
||||
subBlocks: [ /* see SubBlock Properties below */ ],
|
||||
tools: {
|
||||
access: ['{service}_{action}', ...],
|
||||
config: {
|
||||
tool: (params) => `{service}_${params.operation}`,
|
||||
params: (params) => ({ ...params }),
|
||||
},
|
||||
},
|
||||
inputs: { /* input definitions */ },
|
||||
outputs: { /* output definitions */ },
|
||||
}
|
||||
```
|
||||
|
||||
### SubBlock Properties
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'fieldName', // Unique identifier
|
||||
title: 'Field Label', // UI label
|
||||
type: 'short-input', // See SubBlock Types below
|
||||
placeholder: 'Hint text',
|
||||
required: true, // See Required below
|
||||
condition: { ... }, // See Condition below
|
||||
dependsOn: ['otherField'], // See DependsOn below
|
||||
mode: 'basic', // 'basic' | 'advanced' | 'both' | 'trigger'
|
||||
}
|
||||
```
|
||||
|
||||
**SubBlock Types:** `short-input`, `long-input`, `dropdown`, `code`, `switch`, `slider`, `oauth-input`, `channel-selector`, `user-selector`, `file-upload`, etc.
|
||||
|
||||
### `condition` - Show/hide based on another field
|
||||
|
||||
```typescript
|
||||
// Show when operation === 'send'
|
||||
condition: { field: 'operation', value: 'send' }
|
||||
|
||||
// Show when operation is 'send' OR 'read'
|
||||
condition: { field: 'operation', value: ['send', 'read'] }
|
||||
|
||||
// Show when operation !== 'send'
|
||||
condition: { field: 'operation', value: 'send', not: true }
|
||||
|
||||
// Complex: NOT in list AND another condition
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['list_channels', 'list_users'],
|
||||
not: true,
|
||||
and: { field: 'destinationType', value: 'dm', not: true }
|
||||
}
|
||||
```
|
||||
|
||||
### `required` - Field validation
|
||||
|
||||
```typescript
|
||||
// Always required
|
||||
required: true
|
||||
|
||||
// Conditionally required (same syntax as condition)
|
||||
required: { field: 'operation', value: 'send' }
|
||||
```
|
||||
|
||||
### `dependsOn` - Clear field when dependencies change
|
||||
|
||||
```typescript
|
||||
// Clear when credential changes
|
||||
dependsOn: ['credential']
|
||||
|
||||
// Clear when authMethod changes AND (credential OR botToken) changes
|
||||
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }
|
||||
```
|
||||
|
||||
### `mode` - When to show field
|
||||
|
||||
- `'basic'` - Only in basic mode (default UI)
|
||||
- `'advanced'` - Only in advanced mode (manual input)
|
||||
- `'both'` - Show in both modes (default)
|
||||
- `'trigger'` - Only when block is used as trigger
|
||||
|
||||
**Register in `blocks/registry.ts`:**
|
||||
|
||||
```typescript
|
||||
import { {Service}Block } from '@/blocks/blocks/{service}'
|
||||
// Add to registry object (alphabetically)
|
||||
{service}: {Service}Block,
|
||||
```
|
||||
|
||||
## 3. Icon (`components/icons.tsx`)
|
||||
|
||||
```typescript
|
||||
export function {Service}Icon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* SVG path from service's brand assets */}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Trigger (`triggers/{service}/`) - Optional
|
||||
|
||||
```
|
||||
triggers/{service}/
|
||||
├── index.ts # Export all triggers
|
||||
├── webhook.ts # Webhook handler
|
||||
├── utils.ts # Shared utilities
|
||||
└── {event}.ts # Specific event handlers
|
||||
```
|
||||
|
||||
**Register in `triggers/registry.ts`:**
|
||||
|
||||
```typescript
|
||||
import { {service}WebhookTrigger } from '@/triggers/{service}'
|
||||
// Add to TRIGGER_REGISTRY
|
||||
{service}_webhook: {service}WebhookTrigger,
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Look up API docs for the service
|
||||
- [ ] Create `tools/{service}/types.ts` with proper types
|
||||
- [ ] Create tool files for each operation
|
||||
- [ ] Create `tools/{service}/index.ts` barrel export
|
||||
- [ ] Register tools in `tools/registry.ts`
|
||||
- [ ] Add icon to `components/icons.tsx`
|
||||
- [ ] Create block in `blocks/blocks/{service}.ts`
|
||||
- [ ] Register block in `blocks/registry.ts`
|
||||
- [ ] (Optional) Create triggers in `triggers/{service}/`
|
||||
- [ ] (Optional) Register triggers in `triggers/registry.ts`
|
||||
66
.claude/rules/sim-queries.md
Normal file
66
.claude/rules/sim-queries.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/hooks/queries/**/*.ts"
|
||||
---
|
||||
|
||||
# React Query Patterns
|
||||
|
||||
All React Query hooks live in `hooks/queries/`.
|
||||
|
||||
## Query Key Factory
|
||||
|
||||
Every query file defines a keys factory:
|
||||
|
||||
```typescript
|
||||
export const entityKeys = {
|
||||
all: ['entity'] as const,
|
||||
list: (workspaceId?: string) => [...entityKeys.all, 'list', workspaceId ?? ''] as const,
|
||||
detail: (id?: string) => [...entityKeys.all, 'detail', id ?? ''] as const,
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```typescript
|
||||
// 1. Query keys factory
|
||||
// 2. Types (if needed)
|
||||
// 3. Private fetch functions
|
||||
// 4. Exported hooks
|
||||
```
|
||||
|
||||
## Query Hook
|
||||
|
||||
```typescript
|
||||
export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: entityKeys.list(workspaceId),
|
||||
queryFn: () => fetchEntities(workspaceId as string),
|
||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Mutation Hook
|
||||
|
||||
```typescript
|
||||
export function useCreateEntity() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (variables) => { /* fetch POST */ },
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: entityKeys.all }),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Optimistic Updates
|
||||
|
||||
For optimistic mutations syncing with Zustand, use `createOptimisticMutationHandlers` from `@/hooks/queries/utils/optimistic-mutation`.
|
||||
|
||||
## Naming
|
||||
|
||||
- **Keys**: `entityKeys`
|
||||
- **Query hooks**: `useEntity`, `useEntityList`
|
||||
- **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`
|
||||
- **Fetch functions**: `fetchEntity` (private)
|
||||
71
.claude/rules/sim-stores.md
Normal file
71
.claude/rules/sim-stores.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/store.ts"
|
||||
- "apps/sim/**/stores/**/*.ts"
|
||||
---
|
||||
|
||||
# Zustand Store Patterns
|
||||
|
||||
Stores live in `stores/`. Complex stores split into `store.ts` + `types.ts`.
|
||||
|
||||
## Basic Store
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import type { FeatureState } from '@/stores/feature/types'
|
||||
|
||||
const initialState = { items: [] as Item[], activeId: null as string | null }
|
||||
|
||||
export const useFeatureStore = create<FeatureState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
setItems: (items) => set({ items }),
|
||||
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
{ name: 'feature-store' }
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Persisted Store
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export const useFeatureStore = create<FeatureState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
width: 300,
|
||||
setWidth: (width) => set({ width }),
|
||||
_hasHydrated: false,
|
||||
setHasHydrated: (v) => set({ _hasHydrated: v }),
|
||||
}),
|
||||
{
|
||||
name: 'feature-state',
|
||||
partialize: (state) => ({ width: state.width }),
|
||||
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
|
||||
}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. Use `devtools` middleware (named stores)
|
||||
2. Use `persist` only when data should survive reload
|
||||
3. `partialize` to persist only necessary state
|
||||
4. `_hasHydrated` pattern for persisted stores needing hydration tracking
|
||||
5. Immutable updates only
|
||||
6. `set((state) => ...)` when depending on previous state
|
||||
7. Provide `reset()` action
|
||||
|
||||
## Outside React
|
||||
|
||||
```typescript
|
||||
const items = useFeatureStore.getState().items
|
||||
useFeatureStore.setState({ items: newItems })
|
||||
```
|
||||
41
.claude/rules/sim-styling.md
Normal file
41
.claude/rules/sim-styling.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.tsx"
|
||||
- "apps/sim/**/*.css"
|
||||
---
|
||||
|
||||
# Styling Rules
|
||||
|
||||
## Tailwind
|
||||
|
||||
1. **No inline styles** - Use Tailwind classes
|
||||
2. **No duplicate dark classes** - Skip `dark:` when value matches light mode
|
||||
3. **Exact values** - `text-[14px]`, `h-[26px]`
|
||||
4. **Transitions** - `transition-colors` for interactive states
|
||||
|
||||
## Conditional Classes
|
||||
|
||||
```typescript
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
<div className={cn(
|
||||
'base-classes',
|
||||
isActive && 'active-classes',
|
||||
disabled ? 'opacity-60' : 'hover:bg-accent'
|
||||
)} />
|
||||
```
|
||||
|
||||
## CSS Variables
|
||||
|
||||
For dynamic values (widths, heights) synced with stores:
|
||||
|
||||
```typescript
|
||||
// In store
|
||||
setWidth: (width) => {
|
||||
set({ width })
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
|
||||
}
|
||||
|
||||
// In component
|
||||
<aside style={{ width: 'var(--sidebar-width)' }} />
|
||||
```
|
||||
58
.claude/rules/sim-testing.md
Normal file
58
.claude/rules/sim-testing.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.test.ts"
|
||||
- "apps/sim/**/*.test.tsx"
|
||||
---
|
||||
|
||||
# Testing Patterns
|
||||
|
||||
Use Vitest. Test files: `feature.ts` → `feature.test.ts`
|
||||
|
||||
## Structure
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { databaseMock, loggerMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => databaseMock)
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
import { myFunction } from '@/lib/feature'
|
||||
|
||||
describe('myFunction', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it.concurrent('isolated tests run in parallel', () => { ... })
|
||||
})
|
||||
```
|
||||
|
||||
## @sim/testing Package
|
||||
|
||||
Always prefer over local mocks.
|
||||
|
||||
| Category | Utilities |
|
||||
|----------|-----------|
|
||||
| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` |
|
||||
| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` |
|
||||
| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` |
|
||||
| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` |
|
||||
|
||||
## Rules
|
||||
|
||||
1. `@vitest-environment node` directive at file top
|
||||
2. `vi.mock()` calls before importing mocked modules
|
||||
3. `@sim/testing` utilities over local mocks
|
||||
4. `it.concurrent` for isolated tests (no shared mutable state)
|
||||
5. `beforeEach(() => vi.clearAllMocks())` to reset state
|
||||
|
||||
## Hoisted Mocks
|
||||
|
||||
For mutable mock references:
|
||||
|
||||
```typescript
|
||||
const mockFn = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/module', () => ({ myFunction: mockFn }))
|
||||
mockFn.mockResolvedValue({ data: 'test' })
|
||||
```
|
||||
21
.claude/rules/sim-typescript.md
Normal file
21
.claude/rules/sim-typescript.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/sim/**/*.ts"
|
||||
- "apps/sim/**/*.tsx"
|
||||
---
|
||||
|
||||
# TypeScript Rules
|
||||
|
||||
1. **No `any`** - Use proper types or `unknown` with type guards
|
||||
2. **Props interface** - Always define for components
|
||||
3. **Const assertions** - `as const` for constant objects/arrays
|
||||
4. **Ref types** - Explicit: `useRef<HTMLDivElement>(null)`
|
||||
5. **Type imports** - `import type { X }` for type-only imports
|
||||
|
||||
```typescript
|
||||
// ✗ Bad
|
||||
const handleClick = (e: any) => {}
|
||||
|
||||
// ✓ Good
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
|
||||
```
|
||||
@@ -8,7 +8,7 @@ alwaysApply: true
|
||||
You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.
|
||||
|
||||
## Logging
|
||||
Import `createLogger` from `sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
|
||||
Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
|
||||
|
||||
## Comments
|
||||
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
|
||||
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
|
||||
</p>
|
||||
|
||||
### Build Workflows with Ease
|
||||
|
||||
@@ -4093,6 +4093,23 @@ export function SQSIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function TextractIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='10 14 60 52'
|
||||
version='1.1'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
xmlnsXlink='http://www.w3.org/1999/xlink'
|
||||
>
|
||||
<path
|
||||
d='M22.0624102,50 C24.3763895,53.603 28.4103535,56 33.0003125,56 C40.1672485,56 45.9991964,50.168 45.9991964,43 C45.9991964,35.832 40.1672485,30 33.0003125,30 C27.6033607,30 22.9664021,33.307 21.0024196,38 L23.2143999,38 C25.0393836,34.444 28.7363506,32 33.0003125,32 C39.0652583,32 43.9992143,36.935 43.9992143,43 C43.9992143,49.065 39.0652583,54 33.0003125,54 C29.5913429,54 26.5413702,52.441 24.5213882,50 L22.0624102,50 Z M37.0002768,45 L37.0002768,43 L41.9992321,43 C41.9992321,38.038 37.9622682,34 33.0003125,34 C28.0373568,34 23.9993929,38.038 23.9993929,43 L28.9993482,43 L28.9993482,45 L24.2313908,45 C25.1443826,49.002 28.7253507,52 33.0003125,52 C35.1362934,52 37.0992759,51.249 38.6442621,50 L34.0003036,50 L34.0003036,48 L40.4782457,48 C41.0812403,47.102 41.5202364,46.087 41.7682342,45 L37.0002768,45 Z M21.0024196,48 L23.2143999,48 C22.4434068,46.498 22.0004107,44.801 22.0004107,43 C22.0004107,41.959 22.1554093,40.955 22.4264069,40 L20.3634253,40 C20.1344274,40.965 19.9994286,41.966 19.9994286,43 C19.9994286,44.771 20.3584254,46.46 21.0024196,48 L21.0024196,48 Z M19.7434309,50 L17.0004554,50 L17.0004554,48 L18.8744386,48 C18.5344417,47.04 18.2894438,46.038 18.1494451,45 L15.4144695,45 L16.707458,46.293 L15.2924706,47.707 L12.2924974,44.707 C11.9025009,44.316 11.9025009,43.684 12.2924974,43.293 L15.2924706,40.293 L16.707458,41.707 L15.4144695,43 L18.0004464,43 C18.0004464,41.973 18.1044455,40.97 18.3024437,40 L17.0004554,40 L17.0004554,38 L18.8744386,38 C20.9404202,32.184 26.4833707,28 33.0003125,28 C37.427273,28 41.4002375,29.939 44.148213,33 L59.0000804,33 L59.0000804,35 L45.6661994,35 C47.1351863,37.318 47.9991786,40.058 47.9991786,43 L59.0000804,43 L59.0000804,45 L47.8501799,45 C46.8681887,52.327 40.5912447,58 33.0003125,58 C27.2563638,58 22.2624084,54.752 19.7434309,50 L19.7434309,50 Z M37.0002768,39 C37.0002768,38.448 36.5522808,38 36.0002857,38 L29.9993482,38 C29.4473442,38 28.9993482,38.448 28.9993482,39 L28.9993482,41 L31.0003304,41 L31.0003304,40 L32.0003214,40 L32.0003214,43 L31.0003304,43 L31.0003304,45 L35.0002946,45 L35.0002946,43 L34.0003036,43 L34.0003036,40 L35.0002946,40 L35.0002946,41 L37.0002768,41 L37.0002768,39 Z M49.0001696,40 L59.0000804,40 L59.0000804,38 L49.0001696,38 L49.0001696,40 Z M49.0001696,50 L59.0000804,50 L59.0000804,48 L49.0001696,48 L49.0001696,50 Z M57.0000982,27 L60.5850662,27 L57.0000982,23.414 L57.0000982,27 Z M63.7070383,27.293 C63.8940367,27.48 64.0000357,27.735 64.0000357,28 L64.0000357,63 C64.0000357,63.552 63.5520397,64 63.0000446,64 L32.0003304,64 C31.4473264,64 31.0003304,63.552 31.0003304,63 L31.0003304,59 L33.0003125,59 L33.0003125,62 L62.0000536,62 L62.0000536,29 L56.0001071,29 C55.4471121,29 55.0001161,28.552 55.0001161,28 L55.0001161,22 L33.0003125,22 L33.0003125,27 L31.0003304,27 L31.0003304,21 C31.0003304,20.448 31.4473264,20 32.0003304,20 L56.0001071,20 C56.2651048,20 56.5191025,20.105 56.7071008,20.293 L63.7070383,27.293 Z M68,24.166 L68,61 C68,61.552 67.552004,62 67.0000089,62 L65.0000268,62 L65.0000268,60 L66.0000179,60 L66.0000179,24.612 L58.6170838,18 L36.0002857,18 L36.0002857,19 L34.0003036,19 L34.0003036,17 C34.0003036,16.448 34.4472996,16 35.0003036,16 L59.0000804,16 C59.2460782,16 59.483076,16.091 59.6660744,16.255 L67.666003,23.42 C67.8780011,23.61 68,23.881 68,24.166 L68,24.166 Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function McpIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
@@ -4679,6 +4696,26 @@ export function BedrockIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function TableIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<rect width='18' height='18' x='3' y='3' rx='2' />
|
||||
<path d='M3 9h18' />
|
||||
<path d='M3 15h18' />
|
||||
<path d='M9 3v18' />
|
||||
<path d='M15 3v18' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export function ReductoIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -108,8 +108,10 @@ import {
|
||||
StagehandIcon,
|
||||
StripeIcon,
|
||||
SupabaseIcon,
|
||||
TableIcon,
|
||||
TavilyIcon,
|
||||
TelegramIcon,
|
||||
TextractIcon,
|
||||
TinybirdIcon,
|
||||
TranslateIcon,
|
||||
TrelloIcon,
|
||||
@@ -143,7 +145,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
calendly: CalendlyIcon,
|
||||
circleback: CirclebackIcon,
|
||||
clay: ClayIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
confluence_v2: ConfluenceIcon,
|
||||
cursor_v2: CursorIcon,
|
||||
datadog: DatadogIcon,
|
||||
discord: DiscordIcon,
|
||||
@@ -153,7 +155,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
exa: ExaAIIcon,
|
||||
file: DocumentIcon,
|
||||
file_v2: DocumentIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
fireflies: FirefliesIcon,
|
||||
github_v2: GithubIcon,
|
||||
@@ -195,7 +197,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
microsoft_excel_v2: MicrosoftExcelIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
mistral_parse_v2: MistralIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
mysql: MySQLIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
@@ -235,8 +237,10 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
stripe: StripeIcon,
|
||||
stt: STTIcon,
|
||||
supabase: SupabaseIcon,
|
||||
table: TableIcon,
|
||||
tavily: TavilyIcon,
|
||||
telegram: TelegramIcon,
|
||||
textract: TextractIcon,
|
||||
tinybird: TinybirdIcon,
|
||||
translate: TranslateIcon,
|
||||
trello: TrelloIcon,
|
||||
@@ -244,7 +248,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
twilio_sms: TwilioIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
typeform: TypeformIcon,
|
||||
video_generator: VideoIcon,
|
||||
video_generator_v2: VideoIcon,
|
||||
vision: EyeIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
webflow: WebflowIcon,
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Interact with Confluence
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="confluence"
|
||||
type="confluence_v2"
|
||||
color="#E0E0E0"
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Read and parse multiple files
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="file"
|
||||
type="file_v2"
|
||||
color="#40916C"
|
||||
/>
|
||||
|
||||
@@ -48,7 +48,7 @@ Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `files` | array | Array of parsed files |
|
||||
| `combinedContent` | string | Combined content of all parsed files |
|
||||
| `files` | array | Array of parsed files with content, metadata, and file properties |
|
||||
| `combinedContent` | string | All file contents merged into a single text string |
|
||||
|
||||
|
||||
|
||||
@@ -104,8 +104,10 @@
|
||||
"stripe",
|
||||
"stt",
|
||||
"supabase",
|
||||
"table",
|
||||
"tavily",
|
||||
"telegram",
|
||||
"textract",
|
||||
"tinybird",
|
||||
"translate",
|
||||
"trello",
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Extract text from PDF documents
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="mistral_parse"
|
||||
type="mistral_parse_v2"
|
||||
color="#000000"
|
||||
/>
|
||||
|
||||
@@ -54,18 +54,37 @@ Parse PDF documents using Mistral OCR API
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the PDF was parsed successfully |
|
||||
| `content` | string | Extracted content in the requested format \(markdown, text, or JSON\) |
|
||||
| `metadata` | object | Processing metadata including jobId, fileType, pageCount, and usage info |
|
||||
| ↳ `jobId` | string | Unique job identifier |
|
||||
| ↳ `fileType` | string | File type \(e.g., pdf\) |
|
||||
| ↳ `fileName` | string | Original file name |
|
||||
| ↳ `source` | string | Source type \(url\) |
|
||||
| ↳ `pageCount` | number | Number of pages processed |
|
||||
| ↳ `model` | string | Mistral model used |
|
||||
| ↳ `resultType` | string | Output format \(markdown, text, json\) |
|
||||
| ↳ `processedAt` | string | Processing timestamp |
|
||||
| ↳ `sourceUrl` | string | Source URL if applicable |
|
||||
| ↳ `usageInfo` | object | Usage statistics from OCR processing |
|
||||
| `pages` | array | Array of page objects from Mistral OCR |
|
||||
| ↳ `index` | number | Page index \(zero-based\) |
|
||||
| ↳ `markdown` | string | Extracted markdown content |
|
||||
| ↳ `images` | array | Images extracted from this page with bounding boxes |
|
||||
| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) |
|
||||
| ↳ `top_left_x` | number | Top-left X coordinate in pixels |
|
||||
| ↳ `top_left_y` | number | Top-left Y coordinate in pixels |
|
||||
| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels |
|
||||
| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels |
|
||||
| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) |
|
||||
| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) |
|
||||
| ↳ `top_left_x` | number | Top-left X coordinate in pixels |
|
||||
| ↳ `top_left_y` | number | Top-left Y coordinate in pixels |
|
||||
| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels |
|
||||
| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels |
|
||||
| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) |
|
||||
| ↳ `dimensions` | object | Page dimensions |
|
||||
| ↳ `dpi` | number | Dots per inch |
|
||||
| ↳ `height` | number | Page height in pixels |
|
||||
| ↳ `width` | number | Page width in pixels |
|
||||
| ↳ `dpi` | number | Dots per inch |
|
||||
| ↳ `height` | number | Page height in pixels |
|
||||
| ↳ `width` | number | Page width in pixels |
|
||||
| ↳ `tables` | array | Extracted tables as HTML/markdown \(when table_format is set\). Referenced via placeholders like \[tbl-0.html\] |
|
||||
| ↳ `hyperlinks` | array | Array of URL strings detected in the page \(e.g., \[ |
|
||||
| ↳ `header` | string | Page header content \(when extract_header=true\) |
|
||||
| ↳ `footer` | string | Page footer content \(when extract_footer=true\) |
|
||||
| `model` | string | Mistral OCR model identifier \(e.g., mistral-ocr-latest\) |
|
||||
| `usage_info` | object | Usage and processing statistics |
|
||||
| ↳ `pages_processed` | number | Total number of pages processed |
|
||||
| ↳ `doc_size_bytes` | number | Document file size in bytes |
|
||||
| `document_annotation` | string | Structured annotation data as JSON string \(when applicable\) |
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ Upload a file to an AWS S3 bucket
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `url` | string | URL of the uploaded S3 object |
|
||||
| `uri` | string | S3 URI of the uploaded object \(s3://bucket/key\) |
|
||||
| `metadata` | object | Upload metadata including ETag and location |
|
||||
|
||||
### `s3_get_object`
|
||||
@@ -149,6 +150,7 @@ Copy an object within or between AWS S3 buckets
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `url` | string | URL of the copied S3 object |
|
||||
| `uri` | string | S3 URI of the copied object \(s3://bucket/key\) |
|
||||
| `metadata` | object | Copy operation metadata |
|
||||
|
||||
|
||||
|
||||
351
apps/docs/content/docs/en/tools/table.mdx
Normal file
351
apps/docs/content/docs/en/tools/table.mdx
Normal file
@@ -0,0 +1,351 @@
|
||||
---
|
||||
title: Table
|
||||
description: User-defined data tables for storing and querying structured data
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="table"
|
||||
color="#10B981"
|
||||
/>
|
||||
|
||||
Tables allow you to create and manage custom data tables directly within Sim. Store, query, and manipulate structured data within your workflows without needing external database integrations.
|
||||
|
||||
**Why Use Tables?**
|
||||
- **No external setup**: Create tables instantly without configuring external databases
|
||||
- **Workflow-native**: Data persists across workflow executions and is accessible from any workflow in your workspace
|
||||
- **Flexible schema**: Define columns with types (string, number, boolean, date, json) and constraints (required, unique)
|
||||
- **Powerful querying**: Filter, sort, and paginate data using MongoDB-style operators
|
||||
- **Agent-friendly**: Tables can be used as tools by AI agents for dynamic data storage and retrieval
|
||||
|
||||
**Key Features:**
|
||||
- Create tables with custom schemas
|
||||
- Insert, update, upsert, and delete rows
|
||||
- Query with filters and sorting
|
||||
- Batch operations for bulk inserts
|
||||
- Bulk updates and deletes by filter
|
||||
- Up to 10,000 rows per table, 100 tables per workspace
|
||||
|
||||
## Creating Tables
|
||||
|
||||
Tables are created from the **Tables** section in the sidebar. Each table requires:
|
||||
- **Name**: Alphanumeric with underscores (e.g., `customer_leads`)
|
||||
- **Description**: Optional description of the table's purpose
|
||||
- **Schema**: Define columns with name, type, and optional constraints
|
||||
|
||||
### Column Types
|
||||
|
||||
| Type | Description | Example Values |
|
||||
|------|-------------|----------------|
|
||||
| `string` | Text data | `"John Doe"`, `"active"` |
|
||||
| `number` | Numeric data | `42`, `99.99` |
|
||||
| `boolean` | True/false values | `true`, `false` |
|
||||
| `date` | Date/time values | `"2024-01-15T10:30:00Z"` |
|
||||
| `json` | Complex nested data | `{"address": {"city": "NYC"}}` |
|
||||
|
||||
### Column Constraints
|
||||
|
||||
- **Required**: Column must have a value (cannot be null)
|
||||
- **Unique**: Values must be unique across all rows (enables upsert matching)
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Create and manage custom data tables. Store, query, and manipulate structured data within workflows.
|
||||
|
||||
## Tools
|
||||
|
||||
### `table_query_rows`
|
||||
|
||||
Query rows from a table with filtering, sorting, and pagination
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `filter` | object | No | Filter conditions using MongoDB-style operators |
|
||||
| `sort` | object | No | Sort order as \{column: "asc"\|"desc"\} |
|
||||
| `limit` | number | No | Maximum rows to return \(default: 100, max: 1000\) |
|
||||
| `offset` | number | No | Number of rows to skip \(default: 0\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether query succeeded |
|
||||
| `rows` | array | Query result rows |
|
||||
| `rowCount` | number | Number of rows returned |
|
||||
| `totalCount` | number | Total rows matching filter |
|
||||
| `limit` | number | Limit used in query |
|
||||
| `offset` | number | Offset used in query |
|
||||
|
||||
### `table_insert_row`
|
||||
|
||||
Insert a new row into a table
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `data` | object | Yes | Row data as JSON object matching the table schema |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether row was inserted |
|
||||
| `row` | object | Inserted row data including generated ID |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_upsert_row`
|
||||
|
||||
Insert or update a row based on unique column constraints. If a row with matching unique field exists, update it; otherwise insert a new row.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `data` | object | Yes | Row data to insert or update |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether row was upserted |
|
||||
| `row` | object | Upserted row data |
|
||||
| `operation` | string | Operation performed: "insert" or "update" |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_batch_insert_rows`
|
||||
|
||||
Insert multiple rows at once (up to 1000 rows per batch)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `rows` | array | Yes | Array of row data objects to insert |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether batch insert succeeded |
|
||||
| `rows` | array | Array of inserted rows with IDs |
|
||||
| `insertedCount` | number | Number of rows inserted |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_update_row`
|
||||
|
||||
Update a specific row by its ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `rowId` | string | Yes | Row ID to update |
|
||||
| `data` | object | Yes | Data to update \(partial update supported\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether row was updated |
|
||||
| `row` | object | Updated row data |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_update_rows_by_filter`
|
||||
|
||||
Update multiple rows matching a filter condition
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `filter` | object | Yes | Filter to match rows for update |
|
||||
| `data` | object | Yes | Data to apply to matching rows |
|
||||
| `limit` | number | No | Maximum rows to update \(default: 1000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether update succeeded |
|
||||
| `updatedCount` | number | Number of rows updated |
|
||||
| `updatedRowIds` | array | IDs of updated rows |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_delete_row`
|
||||
|
||||
Delete a specific row by its ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `rowId` | string | Yes | Row ID to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether row was deleted |
|
||||
| `deletedCount` | number | Number of rows deleted \(1 or 0\) |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_delete_rows_by_filter`
|
||||
|
||||
Delete multiple rows matching a filter condition
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `filter` | object | Yes | Filter to match rows for deletion |
|
||||
| `limit` | number | No | Maximum rows to delete \(default: 1000\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether delete succeeded |
|
||||
| `deletedCount` | number | Number of rows deleted |
|
||||
| `deletedRowIds` | array | IDs of deleted rows |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_get_row`
|
||||
|
||||
Get a single row by its ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
| `rowId` | string | Yes | Row ID to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether row was found |
|
||||
| `row` | object | Row data |
|
||||
| `message` | string | Status message |
|
||||
|
||||
### `table_get_schema`
|
||||
|
||||
Get the schema definition for a table
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `tableId` | string | Yes | Table ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether schema was retrieved |
|
||||
| `name` | string | Table name |
|
||||
| `columns` | array | Array of column definitions |
|
||||
| `message` | string | Status message |
|
||||
|
||||
## Filter Operators
|
||||
|
||||
Filters use MongoDB-style operators for flexible querying:
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `$eq` | Equals | `{"status": {"$eq": "active"}}` or `{"status": "active"}` |
|
||||
| `$ne` | Not equals | `{"status": {"$ne": "deleted"}}` |
|
||||
| `$gt` | Greater than | `{"age": {"$gt": 18}}` |
|
||||
| `$gte` | Greater than or equal | `{"score": {"$gte": 80}}` |
|
||||
| `$lt` | Less than | `{"price": {"$lt": 100}}` |
|
||||
| `$lte` | Less than or equal | `{"quantity": {"$lte": 10}}` |
|
||||
| `$in` | In array | `{"status": {"$in": ["active", "pending"]}}` |
|
||||
| `$nin` | Not in array | `{"type": {"$nin": ["spam", "blocked"]}}` |
|
||||
| `$contains` | String contains | `{"email": {"$contains": "@gmail.com"}}` |
|
||||
|
||||
### Combining Filters
|
||||
|
||||
Multiple field conditions are combined with AND logic:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "active",
|
||||
"age": {"$gte": 18}
|
||||
}
|
||||
```
|
||||
|
||||
Use `$or` for OR logic:
|
||||
|
||||
```json
|
||||
{
|
||||
"$or": [
|
||||
{"status": "active"},
|
||||
{"status": "pending"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Sort Specification
|
||||
|
||||
Specify sort order with column names and direction:
|
||||
|
||||
```json
|
||||
{
|
||||
"createdAt": "desc"
|
||||
}
|
||||
```
|
||||
|
||||
Multi-column sorting:
|
||||
|
||||
```json
|
||||
{
|
||||
"priority": "desc",
|
||||
"name": "asc"
|
||||
}
|
||||
```
|
||||
|
||||
## Built-in Columns
|
||||
|
||||
Every row automatically includes:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | string | Unique row identifier |
|
||||
| `createdAt` | date | When the row was created |
|
||||
| `updatedAt` | date | When the row was last modified |
|
||||
|
||||
These can be used in filters and sorting.
|
||||
|
||||
## Limits
|
||||
|
||||
| Resource | Limit |
|
||||
|----------|-------|
|
||||
| Tables per workspace | 100 |
|
||||
| Rows per table | 10,000 |
|
||||
| Columns per table | 50 |
|
||||
| Max row size | 100KB |
|
||||
| String value length | 10,000 characters |
|
||||
| Query limit | 1,000 rows |
|
||||
| Batch insert size | 1,000 rows |
|
||||
| Bulk update/delete | 1,000 rows |
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `blocks`
|
||||
- Type: `table`
|
||||
- Tables are scoped to workspaces and accessible from any workflow within that workspace
|
||||
- Data persists across workflow executions
|
||||
- Use unique constraints to enable upsert functionality
|
||||
- The visual filter/sort builder provides an easy way to construct queries without writing JSON
|
||||
120
apps/docs/content/docs/en/tools/textract.mdx
Normal file
120
apps/docs/content/docs/en/tools/textract.mdx
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: AWS Textract
|
||||
description: Extract text, tables, and forms from documents
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="textract"
|
||||
color="linear-gradient(135deg, #055F4E 0%, #56C0A7 100%)"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[AWS Textract](https://aws.amazon.com/textract/) is a powerful AI service from Amazon Web Services designed to automatically extract printed text, handwriting, tables, forms, key-value pairs, and other structured data from scanned documents and images. Textract leverages advanced optical character recognition (OCR) and document analysis to transform documents into actionable data, enabling automation, analytics, compliance, and more.
|
||||
|
||||
With AWS Textract, you can:
|
||||
|
||||
- **Extract text from images and documents**: Recognize printed text and handwriting in formats such as PDF, JPEG, PNG, or TIFF
|
||||
- **Detect and extract tables**: Automatically find tables and output their structured content
|
||||
- **Parse forms and key-value pairs**: Pull structured data from forms, including fields and their corresponding values
|
||||
- **Identify signatures and layout features**: Detect signatures, geometric layout, and relationships between document elements
|
||||
- **Customize extraction with queries**: Extract specific fields and answers using query-based extraction (e.g., "What is the invoice number?")
|
||||
|
||||
In Sim, the AWS Textract integration empowers your agents to intelligently process documents as part of their workflows. This unlocks automation scenarios such as data entry from invoices, onboarding documents, contracts, receipts, and more. Your agents can extract relevant data, analyze structured forms, and generate summaries or reports directly from document uploads or URLs. By connecting Sim with AWS Textract, you can reduce manual effort, improve data accuracy, and streamline your business processes with robust document understanding.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate AWS Textract into your workflow to extract text, tables, forms, and key-value pairs from documents. Single-page mode supports JPEG, PNG, and single-page PDF. Multi-page mode supports multi-page PDF and TIFF.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `textract_parser`
|
||||
|
||||
Parse documents using AWS Textract OCR and document analysis
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `accessKeyId` | string | Yes | AWS Access Key ID |
|
||||
| `secretAccessKey` | string | Yes | AWS Secret Access Key |
|
||||
| `region` | string | Yes | AWS region for Textract service \(e.g., us-east-1\) |
|
||||
| `processingMode` | string | No | Document type: single-page or multi-page. Defaults to single-page. |
|
||||
| `filePath` | string | No | URL to a document to be processed \(JPEG, PNG, or single-page PDF\). |
|
||||
| `s3Uri` | string | No | S3 URI for multi-page processing \(s3://bucket/key\). |
|
||||
| `fileUpload` | object | No | File upload data from file-upload component |
|
||||
| `featureTypes` | array | No | Feature types to detect: TABLES, FORMS, QUERIES, SIGNATURES, LAYOUT. If not specified, only text detection is performed. |
|
||||
| `items` | string | No | Feature type |
|
||||
| `queries` | array | No | Custom queries to extract specific information. Only used when featureTypes includes QUERIES. |
|
||||
| `items` | object | No | Query configuration |
|
||||
| `properties` | string | No | The query text |
|
||||
| `Text` | string | No | No description |
|
||||
| `Alias` | string | No | No description |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `blocks` | array | Array of Block objects containing detected text, tables, forms, and other elements |
|
||||
| ↳ `BlockType` | string | Type of block \(PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.\) |
|
||||
| ↳ `Id` | string | Unique identifier for the block |
|
||||
| ↳ `Text` | string | Query text |
|
||||
| ↳ `TextType` | string | Type of text \(PRINTED or HANDWRITING\) |
|
||||
| ↳ `Confidence` | number | Confidence score \(0-100\) |
|
||||
| ↳ `Page` | number | Page number |
|
||||
| ↳ `Geometry` | object | Location and bounding box information |
|
||||
| ↳ `BoundingBox` | object | Height as ratio of document height |
|
||||
| ↳ `Height` | number | Height as ratio of document height |
|
||||
| ↳ `Left` | number | Left position as ratio of document width |
|
||||
| ↳ `Top` | number | Top position as ratio of document height |
|
||||
| ↳ `Width` | number | Width as ratio of document width |
|
||||
| ↳ `Height` | number | Height as ratio of document height |
|
||||
| ↳ `Left` | number | Left position as ratio of document width |
|
||||
| ↳ `Top` | number | Top position as ratio of document height |
|
||||
| ↳ `Width` | number | Width as ratio of document width |
|
||||
| ↳ `Polygon` | array | Polygon coordinates |
|
||||
| ↳ `X` | number | X coordinate |
|
||||
| ↳ `Y` | number | Y coordinate |
|
||||
| ↳ `X` | number | X coordinate |
|
||||
| ↳ `Y` | number | Y coordinate |
|
||||
| ↳ `BoundingBox` | object | Height as ratio of document height |
|
||||
| ↳ `Height` | number | Height as ratio of document height |
|
||||
| ↳ `Left` | number | Left position as ratio of document width |
|
||||
| ↳ `Top` | number | Top position as ratio of document height |
|
||||
| ↳ `Width` | number | Width as ratio of document width |
|
||||
| ↳ `Height` | number | Height as ratio of document height |
|
||||
| ↳ `Left` | number | Left position as ratio of document width |
|
||||
| ↳ `Top` | number | Top position as ratio of document height |
|
||||
| ↳ `Width` | number | Width as ratio of document width |
|
||||
| ↳ `Polygon` | array | Polygon coordinates |
|
||||
| ↳ `X` | number | X coordinate |
|
||||
| ↳ `Y` | number | Y coordinate |
|
||||
| ↳ `X` | number | X coordinate |
|
||||
| ↳ `Y` | number | Y coordinate |
|
||||
| ↳ `Relationships` | array | Relationships to other blocks |
|
||||
| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) |
|
||||
| ↳ `Ids` | array | IDs of related blocks |
|
||||
| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) |
|
||||
| ↳ `Ids` | array | IDs of related blocks |
|
||||
| ↳ `EntityTypes` | array | Entity types for KEY_VALUE_SET \(KEY or VALUE\) |
|
||||
| ↳ `SelectionStatus` | string | For checkboxes: SELECTED or NOT_SELECTED |
|
||||
| ↳ `RowIndex` | number | Row index for table cells |
|
||||
| ↳ `ColumnIndex` | number | Column index for table cells |
|
||||
| ↳ `RowSpan` | number | Row span for merged cells |
|
||||
| ↳ `ColumnSpan` | number | Column span for merged cells |
|
||||
| ↳ `Query` | object | Query information for QUERY blocks |
|
||||
| ↳ `Text` | string | Query text |
|
||||
| ↳ `Alias` | string | Query alias |
|
||||
| ↳ `Pages` | array | Pages to search |
|
||||
| ↳ `Alias` | string | Query alias |
|
||||
| ↳ `Pages` | array | Pages to search |
|
||||
| `documentMetadata` | object | Metadata about the analyzed document |
|
||||
| ↳ `pages` | number | Number of pages in the document |
|
||||
| `modelVersion` | string | Version of the Textract model used for processing |
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Generate videos from text using AI
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="video_generator"
|
||||
type="video_generator_v2"
|
||||
color="#181C1E"
|
||||
/>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
const logger = createLogger('LoginForm')
|
||||
|
||||
@@ -105,7 +106,7 @@ export default function LoginPage({
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||
|
||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||
@@ -123,6 +124,7 @@ export default function LoginPage({
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
@@ -139,32 +141,12 @@ export default function LoginPage({
|
||||
|
||||
const inviteFlow = searchParams.get('invite_flow') === 'true'
|
||||
setIsInviteFlow(inviteFlow)
|
||||
}
|
||||
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('branded-button-custom')
|
||||
} else {
|
||||
setButtonClass('branded-button-gradient')
|
||||
const resetSuccess = searchParams.get('resetSuccess') === 'true'
|
||||
if (resetSuccess) {
|
||||
setResetSuccessMessage('Password reset successful. Please sign in with your new password.')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -221,6 +203,7 @@ export default function LoginPage({
|
||||
|
||||
try {
|
||||
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
|
||||
let errorHandled = false
|
||||
|
||||
const result = await client.signIn.email(
|
||||
{
|
||||
@@ -231,11 +214,16 @@ export default function LoginPage({
|
||||
{
|
||||
onError: (ctx) => {
|
||||
logger.error('Login error:', ctx.error)
|
||||
const errorMessage: string[] = ['Invalid email or password']
|
||||
|
||||
// EMAIL_NOT_VERIFIED is handled by the catch block which redirects to /verify
|
||||
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||
errorHandled = true
|
||||
return
|
||||
}
|
||||
|
||||
errorHandled = true
|
||||
const errorMessage: string[] = ['Invalid email or password']
|
||||
|
||||
if (
|
||||
ctx.error.code?.includes('BAD_REQUEST') ||
|
||||
ctx.error.message?.includes('Email and password sign in is not enabled')
|
||||
@@ -271,6 +259,7 @@ export default function LoginPage({
|
||||
errorMessage.push('Too many requests. Please wait a moment before trying again.')
|
||||
}
|
||||
|
||||
setResetSuccessMessage(null)
|
||||
setPasswordErrors(errorMessage)
|
||||
setShowValidationError(true)
|
||||
},
|
||||
@@ -278,9 +267,22 @@ export default function LoginPage({
|
||||
)
|
||||
|
||||
if (!result || result.error) {
|
||||
// Show error if not already handled by onError callback
|
||||
if (!errorHandled) {
|
||||
setResetSuccessMessage(null)
|
||||
const errorMessage = result?.error?.message || 'Login failed. Please try again.'
|
||||
setPasswordErrors([errorMessage])
|
||||
setShowValidationError(true)
|
||||
}
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear reset success message on successful login
|
||||
setResetSuccessMessage(null)
|
||||
|
||||
// Explicit redirect fallback if better-auth doesn't redirect
|
||||
router.push(safeCallbackUrl)
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -400,6 +402,13 @@ export default function LoginPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password reset success message */}
|
||||
{resetSuccessMessage && (
|
||||
<div className={`${inter.className} mt-1 space-y-1 text-[#4CAF50] text-xs`}>
|
||||
<p>{resetSuccessMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email/Password Form - show unless explicitly disabled */}
|
||||
{!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && (
|
||||
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
interface RequestResetFormProps {
|
||||
email: string
|
||||
@@ -27,36 +28,9 @@ export function RequestResetForm({
|
||||
statusMessage,
|
||||
className,
|
||||
}: RequestResetFormProps) {
|
||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('branded-button-custom')
|
||||
} else {
|
||||
setButtonClass('branded-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit(email)
|
||||
@@ -138,36 +112,9 @@ export function SetNewPasswordForm({
|
||||
const [validationMessage, setValidationMessage] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('branded-button-custom')
|
||||
} else {
|
||||
setButtonClass('branded-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
const logger = createLogger('SignupForm')
|
||||
|
||||
@@ -95,7 +96,7 @@ function SignupFormContent({
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [redirectUrl, setRedirectUrl] = useState('')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
@@ -126,31 +127,6 @@ function SignupFormContent({
|
||||
if (inviteFlowParam === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('branded-button-custom')
|
||||
} else {
|
||||
setButtonClass('branded-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const validatePassword = (passwordValue: string): string[] => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
const logger = createLogger('SSOForm')
|
||||
|
||||
@@ -57,7 +58,7 @@ export default function SSOForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,31 +91,6 @@ export default function SSOForm() {
|
||||
setShowEmailValidationError(true)
|
||||
}
|
||||
}
|
||||
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('branded-button-custom')
|
||||
} else {
|
||||
setButtonClass('branded-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { useVerification } from '@/app/(auth)/verify/use-verification'
|
||||
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
|
||||
|
||||
interface VerifyContentProps {
|
||||
hasEmailService: boolean
|
||||
@@ -58,34 +59,7 @@ function VerificationForm({
|
||||
setCountdown(30)
|
||||
}
|
||||
|
||||
const [buttonClass, setButtonClass] = useState('branded-button-gradient')
|
||||
|
||||
useEffect(() => {
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('branded-button-custom')
|
||||
} else {
|
||||
setButtonClass('branded-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -15,7 +15,8 @@ const resetPasswordSchema = z.object({
|
||||
.max(100, 'Password must not exceed 100 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number'),
|
||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
const logger = createLogger('SSO-Providers')
|
||||
const logger = createLogger('SSOProvidersRoute')
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { hasSSOAccess } from '@/lib/billing'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
|
||||
const logger = createLogger('SSO-Register')
|
||||
const logger = createLogger('SSORegisterRoute')
|
||||
|
||||
const mappingSchema = z
|
||||
.object({
|
||||
@@ -43,6 +43,10 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
|
||||
])
|
||||
.default(['openid', 'profile', 'email']),
|
||||
pkce: z.boolean().default(true),
|
||||
authorizationEndpoint: z.string().url().optional(),
|
||||
tokenEndpoint: z.string().url().optional(),
|
||||
userInfoEndpoint: z.string().url().optional(),
|
||||
jwksEndpoint: z.string().url().optional(),
|
||||
}),
|
||||
z.object({
|
||||
providerType: z.literal('saml'),
|
||||
@@ -64,12 +68,10 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// SSO plugin must be enabled in Better Auth
|
||||
if (!env.SSO_ENABLED) {
|
||||
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check plan access (enterprise) or env var override
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
@@ -116,7 +118,16 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (providerType === 'oidc') {
|
||||
const { clientId, clientSecret, scopes, pkce } = body
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
scopes,
|
||||
pkce,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
userInfoEndpoint,
|
||||
jwksEndpoint,
|
||||
} = body
|
||||
|
||||
const oidcConfig: any = {
|
||||
clientId,
|
||||
@@ -127,50 +138,104 @@ export async function POST(request: NextRequest) {
|
||||
pkce: pkce ?? true,
|
||||
}
|
||||
|
||||
// Add manual endpoints for providers that might need them
|
||||
// Common patterns for OIDC providers that don't support discovery properly
|
||||
if (
|
||||
issuer.includes('okta.com') ||
|
||||
issuer.includes('auth0.com') ||
|
||||
issuer.includes('identityserver')
|
||||
) {
|
||||
const baseUrl = issuer.includes('/oauth2/default')
|
||||
? issuer.replace('/oauth2/default', '')
|
||||
: issuer.replace('/oauth', '').replace('/v2.0', '').replace('/oauth2', '')
|
||||
oidcConfig.authorizationEndpoint = authorizationEndpoint
|
||||
oidcConfig.tokenEndpoint = tokenEndpoint
|
||||
oidcConfig.userInfoEndpoint = userInfoEndpoint
|
||||
oidcConfig.jwksEndpoint = jwksEndpoint
|
||||
|
||||
// Okta-style endpoints
|
||||
if (issuer.includes('okta.com')) {
|
||||
oidcConfig.authorizationEndpoint = `${baseUrl}/oauth2/default/v1/authorize`
|
||||
oidcConfig.tokenEndpoint = `${baseUrl}/oauth2/default/v1/token`
|
||||
oidcConfig.userInfoEndpoint = `${baseUrl}/oauth2/default/v1/userinfo`
|
||||
oidcConfig.jwksEndpoint = `${baseUrl}/oauth2/default/v1/keys`
|
||||
}
|
||||
// Auth0-style endpoints
|
||||
else if (issuer.includes('auth0.com')) {
|
||||
oidcConfig.authorizationEndpoint = `${baseUrl}/authorize`
|
||||
oidcConfig.tokenEndpoint = `${baseUrl}/oauth/token`
|
||||
oidcConfig.userInfoEndpoint = `${baseUrl}/userinfo`
|
||||
oidcConfig.jwksEndpoint = `${baseUrl}/.well-known/jwks.json`
|
||||
}
|
||||
// Generic OIDC endpoints (IdentityServer, etc.)
|
||||
else {
|
||||
oidcConfig.authorizationEndpoint = `${baseUrl}/connect/authorize`
|
||||
oidcConfig.tokenEndpoint = `${baseUrl}/connect/token`
|
||||
oidcConfig.userInfoEndpoint = `${baseUrl}/connect/userinfo`
|
||||
oidcConfig.jwksEndpoint = `${baseUrl}/.well-known/jwks`
|
||||
}
|
||||
const needsDiscovery =
|
||||
!oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint
|
||||
|
||||
logger.info('Using manual OIDC endpoints for provider', {
|
||||
if (needsDiscovery) {
|
||||
const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
|
||||
try {
|
||||
logger.info('Fetching OIDC discovery document for missing endpoints', {
|
||||
discoveryUrl,
|
||||
hasAuthEndpoint: !!oidcConfig.authorizationEndpoint,
|
||||
hasTokenEndpoint: !!oidcConfig.tokenEndpoint,
|
||||
hasJwksEndpoint: !!oidcConfig.jwksEndpoint,
|
||||
})
|
||||
|
||||
const discoveryResponse = await fetch(discoveryUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
if (!discoveryResponse.ok) {
|
||||
logger.error('Failed to fetch OIDC discovery document', {
|
||||
status: discoveryResponse.status,
|
||||
statusText: discoveryResponse.statusText,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Failed to fetch OIDC discovery document from ${discoveryUrl}. Status: ${discoveryResponse.status}. Provide all endpoints explicitly or verify the issuer URL.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const discovery = await discoveryResponse.json()
|
||||
|
||||
oidcConfig.authorizationEndpoint =
|
||||
oidcConfig.authorizationEndpoint || discovery.authorization_endpoint
|
||||
oidcConfig.tokenEndpoint = oidcConfig.tokenEndpoint || discovery.token_endpoint
|
||||
oidcConfig.userInfoEndpoint = oidcConfig.userInfoEndpoint || discovery.userinfo_endpoint
|
||||
oidcConfig.jwksEndpoint = oidcConfig.jwksEndpoint || discovery.jwks_uri
|
||||
|
||||
logger.info('Merged OIDC endpoints (user-provided + discovery)', {
|
||||
providerId,
|
||||
issuer,
|
||||
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
||||
tokenEndpoint: oidcConfig.tokenEndpoint,
|
||||
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
||||
jwksEndpoint: oidcConfig.jwksEndpoint,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching OIDC discovery document', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
discoveryUrl,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Failed to fetch OIDC discovery document from ${discoveryUrl}. Please verify the issuer URL is correct or provide all endpoints explicitly.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.info('Using explicitly provided OIDC endpoints (all present)', {
|
||||
providerId,
|
||||
provider: issuer.includes('okta.com')
|
||||
? 'Okta'
|
||||
: issuer.includes('auth0.com')
|
||||
? 'Auth0'
|
||||
: 'Generic',
|
||||
authEndpoint: oidcConfig.authorizationEndpoint,
|
||||
issuer,
|
||||
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
||||
tokenEndpoint: oidcConfig.tokenEndpoint,
|
||||
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
||||
jwksEndpoint: oidcConfig.jwksEndpoint,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
!oidcConfig.authorizationEndpoint ||
|
||||
!oidcConfig.tokenEndpoint ||
|
||||
!oidcConfig.jwksEndpoint
|
||||
) {
|
||||
const missing: string[] = []
|
||||
if (!oidcConfig.authorizationEndpoint) missing.push('authorizationEndpoint')
|
||||
if (!oidcConfig.tokenEndpoint) missing.push('tokenEndpoint')
|
||||
if (!oidcConfig.jwksEndpoint) missing.push('jwksEndpoint')
|
||||
|
||||
logger.error('Missing required OIDC endpoints after discovery merge', {
|
||||
missing,
|
||||
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
||||
tokenEndpoint: oidcConfig.tokenEndpoint,
|
||||
jwksEndpoint: oidcConfig.jwksEndpoint,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Missing required OIDC endpoints: ${missing.join(', ')}. Please provide these explicitly or verify the issuer supports OIDC discovery.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
providerConfig.oidcConfig = oidcConfig
|
||||
} else if (providerType === 'saml') {
|
||||
const {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* GET /api/copilot/chat/[chatId]/active-stream
|
||||
*
|
||||
* Check if a chat has an active stream that can be resumed.
|
||||
* Used by the client on page load to detect if there's an in-progress stream.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
getActiveStreamForChat,
|
||||
getChunkCount,
|
||||
getStreamMeta,
|
||||
} from '@/lib/copilot/stream-persistence'
|
||||
|
||||
const logger = createLogger('CopilotActiveStreamAPI')
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ chatId: string }> }
|
||||
) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { chatId } = await params
|
||||
|
||||
logger.info('Active stream check', { chatId, userId: session.user.id })
|
||||
|
||||
// Look up active stream ID from Redis
|
||||
const streamId = await getActiveStreamForChat(chatId)
|
||||
|
||||
if (!streamId) {
|
||||
logger.debug('No active stream found', { chatId })
|
||||
return NextResponse.json({ hasActiveStream: false })
|
||||
}
|
||||
|
||||
// Get stream metadata
|
||||
const meta = await getStreamMeta(streamId)
|
||||
|
||||
if (!meta) {
|
||||
logger.debug('Stream metadata not found', { streamId, chatId })
|
||||
return NextResponse.json({ hasActiveStream: false })
|
||||
}
|
||||
|
||||
// Verify the stream is still active
|
||||
if (meta.status !== 'streaming') {
|
||||
logger.debug('Stream not active', { streamId, chatId, status: meta.status })
|
||||
return NextResponse.json({ hasActiveStream: false })
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (meta.userId !== session.user.id) {
|
||||
logger.warn('Stream belongs to different user', {
|
||||
streamId,
|
||||
chatId,
|
||||
requesterId: session.user.id,
|
||||
ownerId: meta.userId,
|
||||
})
|
||||
return NextResponse.json({ hasActiveStream: false })
|
||||
}
|
||||
|
||||
// Get current chunk count for client to track progress
|
||||
const chunkCount = await getChunkCount(streamId)
|
||||
|
||||
logger.info('Active stream found', {
|
||||
streamId,
|
||||
chatId,
|
||||
chunkCount,
|
||||
toolCallsCount: meta.toolCalls.length,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
hasActiveStream: true,
|
||||
streamId,
|
||||
chunkCount,
|
||||
toolCalls: meta.toolCalls.filter(
|
||||
(tc) => tc.state === 'pending' || tc.state === 'executing'
|
||||
),
|
||||
createdAt: meta.createdAt,
|
||||
updatedAt: meta.updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { after } from 'next/server'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -17,21 +16,6 @@ import {
|
||||
createRequestTracker,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import {
|
||||
type RenderEvent,
|
||||
serializeRenderEvent,
|
||||
} from '@/lib/copilot/render-events'
|
||||
import {
|
||||
appendChunk,
|
||||
appendContent,
|
||||
checkAbortSignal,
|
||||
completeStream,
|
||||
createStream,
|
||||
errorStream,
|
||||
refreshStreamTTL,
|
||||
updateToolCall,
|
||||
} from '@/lib/copilot/stream-persistence'
|
||||
import { transformStream } from '@/lib/copilot/stream-transformer'
|
||||
import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials'
|
||||
import type { CopilotProviderConfig } from '@/lib/copilot/types'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
@@ -508,205 +492,385 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// If streaming is requested, start background processing and return streamId immediately
|
||||
// If streaming is requested, forward the stream and update chat later
|
||||
if (stream && simAgentResponse.body) {
|
||||
// Create stream ID for persistence and resumption
|
||||
const streamId = crypto.randomUUID()
|
||||
|
||||
// Initialize stream state in Redis
|
||||
await createStream({
|
||||
streamId,
|
||||
chatId: actualChatId!,
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
userMessageId: userMessageIdToUse,
|
||||
isClientSession: true,
|
||||
})
|
||||
|
||||
// Save user message to database immediately so it's available on refresh
|
||||
// This is critical for stream resumption - user message must be persisted before stream starts
|
||||
if (currentChat) {
|
||||
const existingMessages = Array.isArray(currentChat.messages) ? currentChat.messages : []
|
||||
const userMessage = {
|
||||
id: userMessageIdToUse,
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
|
||||
...(Array.isArray(contexts) && contexts.length > 0 && { contexts }),
|
||||
...(Array.isArray(contexts) &&
|
||||
contexts.length > 0 && {
|
||||
contentBlocks: [{ type: 'contexts', contexts: contexts as any, timestamp: Date.now() }],
|
||||
}),
|
||||
}
|
||||
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
messages: [...existingMessages, userMessage],
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
|
||||
logger.info(`[${tracker.requestId}] Saved user message before streaming`, {
|
||||
chatId: actualChatId,
|
||||
messageId: userMessageIdToUse,
|
||||
})
|
||||
// Create user message to save
|
||||
const userMessage = {
|
||||
id: userMessageIdToUse, // Consistent ID used for request and persistence
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
|
||||
...(Array.isArray(contexts) && contexts.length > 0 && { contexts }),
|
||||
...(Array.isArray(contexts) &&
|
||||
contexts.length > 0 && {
|
||||
contentBlocks: [{ type: 'contexts', contexts: contexts as any, timestamp: Date.now() }],
|
||||
}),
|
||||
}
|
||||
|
||||
// Track last TTL refresh time
|
||||
const TTL_REFRESH_INTERVAL = 60000 // Refresh TTL every minute
|
||||
// Create a pass-through stream that captures the response
|
||||
const transformedStream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
let assistantContent = ''
|
||||
const toolCalls: any[] = []
|
||||
let buffer = ''
|
||||
const isFirstDone = true
|
||||
let responseIdFromStart: string | undefined
|
||||
let responseIdFromDone: string | undefined
|
||||
// Track tool call progress to identify a safe done event
|
||||
const announcedToolCallIds = new Set<string>()
|
||||
const startedToolExecutionIds = new Set<string>()
|
||||
const completedToolExecutionIds = new Set<string>()
|
||||
let lastDoneResponseId: string | undefined
|
||||
let lastSafeDoneResponseId: string | undefined
|
||||
|
||||
// Capture needed values for background task
|
||||
const capturedChatId = actualChatId!
|
||||
const capturedCurrentChat = currentChat
|
||||
|
||||
// Generate assistant message ID upfront
|
||||
const assistantMessageId = crypto.randomUUID()
|
||||
|
||||
// Start background processing task using the stream transformer
|
||||
// This processes the Sim Agent stream, executes tools, and emits render events
|
||||
// Client will connect to /api/copilot/stream/{streamId} for live updates
|
||||
const backgroundTask = (async () => {
|
||||
// Start title generation if needed (runs in parallel)
|
||||
if (capturedChatId && !capturedCurrentChat?.title && conversationHistory.length === 0) {
|
||||
generateChatTitle(message)
|
||||
.then(async (title) => {
|
||||
if (title) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({ title, updatedAt: new Date() })
|
||||
.where(eq(copilotChats.id, capturedChatId))
|
||||
logger.info(`[${tracker.requestId}] Generated and saved title: ${title}`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
// Track accumulated content for final persistence
|
||||
let accumulatedContent = ''
|
||||
const accumulatedToolCalls: Array<{
|
||||
id: string
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
state: string
|
||||
result?: unknown
|
||||
}> = []
|
||||
|
||||
try {
|
||||
// Use the stream transformer to process the Sim Agent stream
|
||||
await transformStream(simAgentResponse.body!, {
|
||||
streamId,
|
||||
chatId: capturedChatId,
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
userMessageId: userMessageIdToUse,
|
||||
assistantMessageId,
|
||||
|
||||
// Emit render events to Redis for client consumption
|
||||
onRenderEvent: async (event: RenderEvent) => {
|
||||
// Serialize and append to Redis
|
||||
const serialized = serializeRenderEvent(event)
|
||||
await appendChunk(streamId, serialized).catch(() => {})
|
||||
|
||||
// Also update stream metadata for specific events
|
||||
switch (event.type) {
|
||||
case 'text_delta':
|
||||
accumulatedContent += (event as any).content || ''
|
||||
appendContent(streamId, (event as any).content || '').catch(() => {})
|
||||
break
|
||||
case 'tool_pending':
|
||||
updateToolCall(streamId, (event as any).toolCallId, {
|
||||
id: (event as any).toolCallId,
|
||||
name: (event as any).toolName,
|
||||
args: (event as any).args || {},
|
||||
state: 'pending',
|
||||
}).catch(() => {})
|
||||
break
|
||||
case 'tool_executing':
|
||||
updateToolCall(streamId, (event as any).toolCallId, {
|
||||
state: 'executing',
|
||||
}).catch(() => {})
|
||||
break
|
||||
case 'tool_success':
|
||||
updateToolCall(streamId, (event as any).toolCallId, {
|
||||
state: 'success',
|
||||
result: (event as any).result,
|
||||
}).catch(() => {})
|
||||
accumulatedToolCalls.push({
|
||||
id: (event as any).toolCallId,
|
||||
name: (event as any).display?.label || '',
|
||||
args: {},
|
||||
state: 'success',
|
||||
result: (event as any).result,
|
||||
})
|
||||
break
|
||||
case 'tool_error':
|
||||
updateToolCall(streamId, (event as any).toolCallId, {
|
||||
state: 'error',
|
||||
error: (event as any).error,
|
||||
}).catch(() => {})
|
||||
accumulatedToolCalls.push({
|
||||
id: (event as any).toolCallId,
|
||||
name: (event as any).display?.label || '',
|
||||
args: {},
|
||||
state: 'error',
|
||||
})
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
// Persist data at key moments
|
||||
onPersist: async (data) => {
|
||||
if (data.type === 'message_complete') {
|
||||
// Stream complete - save final message to DB
|
||||
await completeStream(streamId, undefined)
|
||||
}
|
||||
},
|
||||
|
||||
// Check for user-initiated abort
|
||||
isAborted: () => {
|
||||
// We'll check Redis for abort signal synchronously cached
|
||||
// For now, return false - proper abort checking can be async in transformer
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
// Update chat with conversationId if available
|
||||
if (capturedCurrentChat) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(copilotChats.id, capturedChatId))
|
||||
// Send chatId as first event
|
||||
if (actualChatId) {
|
||||
const chatIdEvent = `data: ${JSON.stringify({
|
||||
type: 'chat_id',
|
||||
chatId: actualChatId,
|
||||
})}\n\n`
|
||||
controller.enqueue(encoder.encode(chatIdEvent))
|
||||
logger.debug(`[${tracker.requestId}] Sent initial chatId event to client`)
|
||||
}
|
||||
|
||||
logger.info(`[${tracker.requestId}] Background stream processing complete`, {
|
||||
streamId,
|
||||
contentLength: accumulatedContent.length,
|
||||
toolCallsCount: accumulatedToolCalls.length,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Background stream error`, { streamId, error })
|
||||
await errorStream(streamId, error instanceof Error ? error.message : 'Unknown error')
|
||||
}
|
||||
})()
|
||||
// Start title generation in parallel if needed
|
||||
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
|
||||
generateChatTitle(message)
|
||||
.then(async (title) => {
|
||||
if (title) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
title,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
|
||||
// Use after() to ensure background task completes even after response is sent
|
||||
after(() => backgroundTask)
|
||||
const titleEvent = `data: ${JSON.stringify({
|
||||
type: 'title_updated',
|
||||
title: title,
|
||||
})}\n\n`
|
||||
controller.enqueue(encoder.encode(titleEvent))
|
||||
logger.info(`[${tracker.requestId}] Generated and saved title: ${title}`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
|
||||
})
|
||||
} else {
|
||||
logger.debug(`[${tracker.requestId}] Skipping title generation`)
|
||||
}
|
||||
|
||||
// Return streamId immediately - client will connect to stream endpoint
|
||||
logger.info(`[${tracker.requestId}] Returning streamId for client to connect`, {
|
||||
streamId,
|
||||
chatId: capturedChatId,
|
||||
// Forward the sim agent stream and capture assistant response
|
||||
const reader = simAgentResponse.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
// Decode and parse SSE events for logging and capturing content
|
||||
const decodedChunk = decoder.decode(value, { stream: true })
|
||||
buffer += decodedChunk
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || '' // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') continue // Skip empty lines
|
||||
|
||||
if (line.startsWith('data: ') && line.length > 6) {
|
||||
try {
|
||||
const jsonStr = line.slice(6)
|
||||
|
||||
// Check if the JSON string is unusually large (potential streaming issue)
|
||||
if (jsonStr.length > 50000) {
|
||||
// 50KB limit
|
||||
logger.warn(`[${tracker.requestId}] Large SSE event detected`, {
|
||||
size: jsonStr.length,
|
||||
preview: `${jsonStr.substring(0, 100)}...`,
|
||||
})
|
||||
}
|
||||
|
||||
const event = JSON.parse(jsonStr)
|
||||
|
||||
// Log different event types comprehensively
|
||||
switch (event.type) {
|
||||
case 'content':
|
||||
if (event.data) {
|
||||
assistantContent += event.data
|
||||
}
|
||||
break
|
||||
|
||||
case 'reasoning':
|
||||
logger.debug(
|
||||
`[${tracker.requestId}] Reasoning chunk received (${(event.data || event.content || '').length} chars)`
|
||||
)
|
||||
break
|
||||
|
||||
case 'tool_call':
|
||||
if (!event.data?.partial) {
|
||||
toolCalls.push(event.data)
|
||||
if (event.data?.id) {
|
||||
announcedToolCallIds.add(event.data.id)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_generating':
|
||||
if (event.toolCallId) {
|
||||
startedToolExecutionIds.add(event.toolCallId)
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_result':
|
||||
if (event.toolCallId) {
|
||||
completedToolExecutionIds.add(event.toolCallId)
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_error':
|
||||
logger.error(`[${tracker.requestId}] Tool error:`, {
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
error: event.error,
|
||||
success: event.success,
|
||||
})
|
||||
if (event.toolCallId) {
|
||||
completedToolExecutionIds.add(event.toolCallId)
|
||||
}
|
||||
break
|
||||
|
||||
case 'start':
|
||||
if (event.data?.responseId) {
|
||||
responseIdFromStart = event.data.responseId
|
||||
}
|
||||
break
|
||||
|
||||
case 'done':
|
||||
if (event.data?.responseId) {
|
||||
responseIdFromDone = event.data.responseId
|
||||
lastDoneResponseId = responseIdFromDone
|
||||
|
||||
// Mark this done as safe only if no tool call is currently in progress or pending
|
||||
const announced = announcedToolCallIds.size
|
||||
const completed = completedToolExecutionIds.size
|
||||
const started = startedToolExecutionIds.size
|
||||
const hasToolInProgress = announced > completed || started > completed
|
||||
if (!hasToolInProgress) {
|
||||
lastSafeDoneResponseId = responseIdFromDone
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'error':
|
||||
break
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
// Emit to client: rewrite 'error' events into user-friendly assistant message
|
||||
if (event?.type === 'error') {
|
||||
try {
|
||||
const displayMessage: string =
|
||||
(event?.data && (event.data.displayMessage as string)) ||
|
||||
'Sorry, I encountered an error. Please try again.'
|
||||
const formatted = `_${displayMessage}_`
|
||||
// Accumulate so it persists to DB as assistant content
|
||||
assistantContent += formatted
|
||||
// Send as content chunk
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'content', data: formatted })}\n\n`
|
||||
)
|
||||
)
|
||||
} catch (enqueueErr) {
|
||||
reader.cancel()
|
||||
break
|
||||
}
|
||||
// Then close this response cleanly for the client
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
|
||||
)
|
||||
} catch (enqueueErr) {
|
||||
reader.cancel()
|
||||
break
|
||||
}
|
||||
} catch {}
|
||||
// Do not forward the original error event
|
||||
} else {
|
||||
// Forward original event to client
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${jsonStr}\n\n`))
|
||||
} catch (enqueueErr) {
|
||||
reader.cancel()
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Enhanced error handling for large payloads and parsing issues
|
||||
const lineLength = line.length
|
||||
const isLargePayload = lineLength > 10000
|
||||
|
||||
if (isLargePayload) {
|
||||
logger.error(
|
||||
`[${tracker.requestId}] Failed to parse large SSE event (${lineLength} chars)`,
|
||||
{
|
||||
error: e,
|
||||
preview: `${line.substring(0, 200)}...`,
|
||||
size: lineLength,
|
||||
}
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
`[${tracker.requestId}] Failed to parse SSE event: "${line.substring(0, 200)}..."`,
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (line.trim() && line !== 'data: [DONE]') {
|
||||
logger.debug(`[${tracker.requestId}] Non-SSE line from sim agent: "${line}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining buffer
|
||||
if (buffer.trim()) {
|
||||
logger.debug(`[${tracker.requestId}] Processing remaining buffer: "${buffer}"`)
|
||||
if (buffer.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = buffer.slice(6)
|
||||
const event = JSON.parse(jsonStr)
|
||||
if (event.type === 'content' && event.data) {
|
||||
assistantContent += event.data
|
||||
}
|
||||
// Forward remaining event, applying same error rewrite behavior
|
||||
if (event?.type === 'error') {
|
||||
const displayMessage: string =
|
||||
(event?.data && (event.data.displayMessage as string)) ||
|
||||
'Sorry, I encountered an error. Please try again.'
|
||||
const formatted = `_${displayMessage}_`
|
||||
assistantContent += formatted
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'content', data: formatted })}\n\n`
|
||||
)
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
|
||||
)
|
||||
} catch (enqueueErr) {
|
||||
reader.cancel()
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${jsonStr}\n\n`))
|
||||
} catch (enqueueErr) {
|
||||
reader.cancel()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`[${tracker.requestId}] Failed to parse final buffer: "${buffer}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log final streaming summary
|
||||
logger.info(`[${tracker.requestId}] Streaming complete summary:`, {
|
||||
totalContentLength: assistantContent.length,
|
||||
toolCallsCount: toolCalls.length,
|
||||
hasContent: assistantContent.length > 0,
|
||||
toolNames: toolCalls.map((tc) => tc?.name).filter(Boolean),
|
||||
})
|
||||
|
||||
// NOTE: Messages are saved by the client via update-messages endpoint with full contentBlocks.
|
||||
// Server only updates conversationId here to avoid overwriting client's richer save.
|
||||
if (currentChat) {
|
||||
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
|
||||
const previousConversationId = currentChat?.conversationId as string | undefined
|
||||
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
|
||||
|
||||
if (responseId) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
updatedAt: new Date(),
|
||||
conversationId: responseId,
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Updated conversationId for chat ${actualChatId}`,
|
||||
{
|
||||
updatedConversationId: responseId,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Error processing stream:`, error)
|
||||
|
||||
// Send an error event to the client before closing so it knows what happened
|
||||
try {
|
||||
const errorMessage =
|
||||
error instanceof Error && error.message === 'terminated'
|
||||
? 'Connection to AI service was interrupted. Please try again.'
|
||||
: 'An unexpected error occurred while processing the response.'
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
// Send error as content so it shows in the chat
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'content', data: `\n\n_${errorMessage}_` })}\n\n`
|
||||
)
|
||||
)
|
||||
// Send done event to properly close the stream on client
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`))
|
||||
} catch (enqueueError) {
|
||||
// Stream might already be closed, that's ok
|
||||
logger.warn(
|
||||
`[${tracker.requestId}] Could not send error event to client:`,
|
||||
enqueueError
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// Controller might already be closed
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
streamId,
|
||||
chatId: capturedChatId,
|
||||
const response = new Response(transformedStream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`[${tracker.requestId}] Returning streaming response to client`, {
|
||||
duration: tracker.getDuration(),
|
||||
chatId: actualChatId,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// For non-streaming responses
|
||||
@@ -735,7 +899,7 @@ export async function POST(req: NextRequest) {
|
||||
// Save messages if we have a chat
|
||||
if (currentChat && responseData.content) {
|
||||
const userMessage = {
|
||||
id: userMessageIdToUse,
|
||||
id: userMessageIdToUse, // Consistent ID used for request and persistence
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@@ -224,7 +224,7 @@ export async function POST(req: NextRequest) {
|
||||
hasApiKey: !!executionParams.apiKey,
|
||||
})
|
||||
|
||||
const result = await executeTool(resolvedToolName, executionParams, true)
|
||||
const result = await executeTool(resolvedToolName, executionParams)
|
||||
|
||||
logger.info(`[${tracker.requestId}] Tool execution complete`, {
|
||||
toolName,
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* POST /api/copilot/stream/[streamId]/abort
|
||||
*
|
||||
* Signal the server to abort an active stream.
|
||||
* The original request handler will check for this signal and cancel the stream.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getStreamMeta, setAbortSignal } from '@/lib/copilot/stream-persistence'
|
||||
|
||||
const logger = createLogger('CopilotStreamAbortAPI')
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ streamId: string }> }
|
||||
) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { streamId } = await params
|
||||
|
||||
logger.info('Stream abort request', { streamId, userId: session.user.id })
|
||||
|
||||
const meta = await getStreamMeta(streamId)
|
||||
|
||||
if (!meta) {
|
||||
logger.info('Stream not found for abort', { streamId })
|
||||
return NextResponse.json({ error: 'Stream not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (meta.userId !== session.user.id) {
|
||||
logger.warn('Unauthorized abort attempt', {
|
||||
streamId,
|
||||
requesterId: session.user.id,
|
||||
ownerId: meta.userId,
|
||||
})
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Stream already finished
|
||||
if (meta.status !== 'streaming') {
|
||||
logger.info('Stream already finished, nothing to abort', {
|
||||
streamId,
|
||||
status: meta.status,
|
||||
})
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Stream already finished',
|
||||
})
|
||||
}
|
||||
|
||||
// Set abort signal in Redis
|
||||
await setAbortSignal(streamId)
|
||||
|
||||
logger.info('Abort signal set for stream', { streamId })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
clearPendingDiff,
|
||||
getPendingDiff,
|
||||
getStreamMeta,
|
||||
setPendingDiff,
|
||||
} from '@/lib/copilot/stream-persistence'
|
||||
|
||||
const logger = createLogger('CopilotPendingDiffAPI')
|
||||
|
||||
/**
|
||||
* GET /api/copilot/stream/[streamId]/pending-diff
|
||||
* Retrieve pending diff state for a stream (used for resumption after page refresh)
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ streamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { streamId } = await params
|
||||
if (!streamId) {
|
||||
return NextResponse.json({ error: 'Stream ID required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify user owns this stream
|
||||
const meta = await getStreamMeta(streamId)
|
||||
if (!meta) {
|
||||
return NextResponse.json({ error: 'Stream not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (meta.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get pending diff
|
||||
const pendingDiff = await getPendingDiff(streamId)
|
||||
|
||||
if (!pendingDiff) {
|
||||
return NextResponse.json({ pendingDiff: null })
|
||||
}
|
||||
|
||||
logger.info('Retrieved pending diff', {
|
||||
streamId,
|
||||
toolCallId: pendingDiff.toolCallId,
|
||||
})
|
||||
|
||||
return NextResponse.json({ pendingDiff })
|
||||
} catch (error) {
|
||||
logger.error('Failed to get pending diff', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/copilot/stream/[streamId]/pending-diff
|
||||
* Store pending diff state for a stream
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ streamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { streamId } = await params
|
||||
if (!streamId) {
|
||||
return NextResponse.json({ error: 'Stream ID required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify user owns this stream
|
||||
const meta = await getStreamMeta(streamId)
|
||||
if (!meta) {
|
||||
return NextResponse.json({ error: 'Stream not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (meta.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { pendingDiff } = body
|
||||
|
||||
if (!pendingDiff || !pendingDiff.toolCallId) {
|
||||
return NextResponse.json({ error: 'Invalid pending diff data' }, { status: 400 })
|
||||
}
|
||||
|
||||
await setPendingDiff(streamId, pendingDiff)
|
||||
|
||||
logger.info('Stored pending diff', {
|
||||
streamId,
|
||||
toolCallId: pendingDiff.toolCallId,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Failed to store pending diff', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/copilot/stream/[streamId]/pending-diff
|
||||
* Clear pending diff state for a stream
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ streamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { streamId } = await params
|
||||
if (!streamId) {
|
||||
return NextResponse.json({ error: 'Stream ID required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify user owns this stream (if it exists - might already be cleaned up)
|
||||
const meta = await getStreamMeta(streamId)
|
||||
if (meta && meta.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
await clearPendingDiff(streamId)
|
||||
|
||||
logger.info('Cleared pending diff', { streamId })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear pending diff', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* GET /api/copilot/stream/[streamId]
|
||||
*
|
||||
* Resume an active copilot stream.
|
||||
* - If stream is still active: returns SSE with replay of missed chunks + live updates via Redis Pub/Sub
|
||||
* - If stream is completed: returns JSON indicating to load from database
|
||||
* - If stream not found: returns 404
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
getChunks,
|
||||
getStreamMeta,
|
||||
subscribeToStream,
|
||||
} from '@/lib/copilot/stream-persistence'
|
||||
|
||||
const logger = createLogger('CopilotStreamResumeAPI')
|
||||
|
||||
const SSE_HEADERS = {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ streamId: string }> }
|
||||
) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { streamId } = await params
|
||||
const fromChunk = parseInt(req.nextUrl.searchParams.get('from') || '0')
|
||||
|
||||
logger.info('Stream resume request', { streamId, fromChunk, userId: session.user.id })
|
||||
|
||||
const meta = await getStreamMeta(streamId)
|
||||
|
||||
if (!meta) {
|
||||
logger.info('Stream not found or expired', { streamId })
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'not_found',
|
||||
message: 'Stream not found or expired. Reload chat from database.',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (meta.userId !== session.user.id) {
|
||||
logger.warn('Unauthorized stream access attempt', {
|
||||
streamId,
|
||||
requesterId: session.user.id,
|
||||
ownerId: meta.userId,
|
||||
})
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Stream completed - tell client to load from database
|
||||
if (meta.status === 'completed') {
|
||||
logger.info('Stream already completed', { streamId, chatId: meta.chatId })
|
||||
return NextResponse.json({
|
||||
status: 'completed',
|
||||
chatId: meta.chatId,
|
||||
message: 'Stream completed. Messages saved to database.',
|
||||
})
|
||||
}
|
||||
|
||||
// Stream errored
|
||||
if (meta.status === 'error') {
|
||||
logger.info('Stream encountered error', { streamId, chatId: meta.chatId })
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
chatId: meta.chatId,
|
||||
message: 'Stream encountered an error.',
|
||||
})
|
||||
}
|
||||
|
||||
// Stream still active - return SSE with replay + live updates
|
||||
logger.info('Resuming active stream', { streamId, chatId: meta.chatId })
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const abortController = new AbortController()
|
||||
|
||||
// Handle client disconnect
|
||||
req.signal.addEventListener('abort', () => {
|
||||
logger.info('Client disconnected from resumed stream', { streamId })
|
||||
abortController.abort()
|
||||
})
|
||||
|
||||
const responseStream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
// 1. Replay missed chunks (single read from Redis LIST)
|
||||
const missedChunks = await getChunks(streamId, fromChunk)
|
||||
logger.info('Replaying missed chunks', {
|
||||
streamId,
|
||||
fromChunk,
|
||||
missedChunkCount: missedChunks.length,
|
||||
})
|
||||
|
||||
for (const chunk of missedChunks) {
|
||||
// Chunks are already in SSE format, just re-encode
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
}
|
||||
|
||||
// 2. Subscribe to live chunks via Redis Pub/Sub (blocking, no polling)
|
||||
await subscribeToStream(
|
||||
streamId,
|
||||
(chunk) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
} catch {
|
||||
// Client disconnected
|
||||
abortController.abort()
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Stream complete - close connection
|
||||
logger.info('Stream completed during resume', { streamId })
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
},
|
||||
abortController.signal
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error in stream resume', {
|
||||
streamId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
abortController.abort()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(responseStream, {
|
||||
headers: {
|
||||
...SSE_HEADERS,
|
||||
'X-Stream-Id': streamId,
|
||||
'X-Chat-Id': meta.chatId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ import { createLogger } from '@sim/logger'
|
||||
import binaryExtensionsList from 'binary-extensions'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
|
||||
import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation'
|
||||
import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
|
||||
import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads'
|
||||
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
|
||||
import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server'
|
||||
import { getFileMetadataByKey } from '@/lib/uploads/server/metadata'
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
import type { UserFile } from '@/executor/types'
|
||||
import '@/lib/uploads/core/setup.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -30,6 +32,12 @@ const logger = createLogger('FilesParseAPI')
|
||||
const MAX_DOWNLOAD_SIZE_BYTES = 100 * 1024 * 1024 // 100 MB
|
||||
const DOWNLOAD_TIMEOUT_MS = 30000 // 30 seconds
|
||||
|
||||
interface ExecutionContext {
|
||||
workspaceId: string
|
||||
workflowId: string
|
||||
executionId: string
|
||||
}
|
||||
|
||||
interface ParseResult {
|
||||
success: boolean
|
||||
content?: string
|
||||
@@ -37,6 +45,7 @@ interface ParseResult {
|
||||
filePath: string
|
||||
originalName?: string // Original filename from database (for workspace files)
|
||||
viewerUrl?: string | null // Viewer URL for the file if available
|
||||
userFile?: UserFile // UserFile object for the raw file
|
||||
metadata?: {
|
||||
fileType: string
|
||||
size: number
|
||||
@@ -70,27 +79,45 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const userId = authResult.userId
|
||||
const requestData = await request.json()
|
||||
const { filePath, fileType, workspaceId } = requestData
|
||||
const { filePath, fileType, workspaceId, workflowId, executionId } = requestData
|
||||
|
||||
if (!filePath || (typeof filePath === 'string' && filePath.trim() === '')) {
|
||||
return NextResponse.json({ success: false, error: 'No file path provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info('File parse request received:', { filePath, fileType, workspaceId, userId })
|
||||
// Build execution context if all required fields are present
|
||||
const executionContext: ExecutionContext | undefined =
|
||||
workspaceId && workflowId && executionId
|
||||
? { workspaceId, workflowId, executionId }
|
||||
: undefined
|
||||
|
||||
logger.info('File parse request received:', {
|
||||
filePath,
|
||||
fileType,
|
||||
workspaceId,
|
||||
userId,
|
||||
hasExecutionContext: !!executionContext,
|
||||
})
|
||||
|
||||
if (Array.isArray(filePath)) {
|
||||
const results = []
|
||||
for (const path of filePath) {
|
||||
if (!path || (typeof path === 'string' && path.trim() === '')) {
|
||||
for (const singlePath of filePath) {
|
||||
if (!singlePath || (typeof singlePath === 'string' && singlePath.trim() === '')) {
|
||||
results.push({
|
||||
success: false,
|
||||
error: 'Empty file path in array',
|
||||
filePath: path || '',
|
||||
filePath: singlePath || '',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await parseFileSingle(path, fileType, workspaceId, userId)
|
||||
const result = await parseFileSingle(
|
||||
singlePath,
|
||||
fileType,
|
||||
workspaceId,
|
||||
userId,
|
||||
executionContext
|
||||
)
|
||||
if (result.metadata) {
|
||||
result.metadata.processingTime = Date.now() - startTime
|
||||
}
|
||||
@@ -106,6 +133,7 @@ export async function POST(request: NextRequest) {
|
||||
fileType: result.metadata?.fileType || 'application/octet-stream',
|
||||
size: result.metadata?.size || 0,
|
||||
binary: false,
|
||||
file: result.userFile,
|
||||
},
|
||||
filePath: result.filePath,
|
||||
viewerUrl: result.viewerUrl,
|
||||
@@ -121,7 +149,7 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
const result = await parseFileSingle(filePath, fileType, workspaceId, userId)
|
||||
const result = await parseFileSingle(filePath, fileType, workspaceId, userId, executionContext)
|
||||
|
||||
if (result.metadata) {
|
||||
result.metadata.processingTime = Date.now() - startTime
|
||||
@@ -137,6 +165,7 @@ export async function POST(request: NextRequest) {
|
||||
fileType: result.metadata?.fileType || 'application/octet-stream',
|
||||
size: result.metadata?.size || 0,
|
||||
binary: false,
|
||||
file: result.userFile,
|
||||
},
|
||||
filePath: result.filePath,
|
||||
viewerUrl: result.viewerUrl,
|
||||
@@ -164,7 +193,8 @@ async function parseFileSingle(
|
||||
filePath: string,
|
||||
fileType: string,
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
executionContext?: ExecutionContext
|
||||
): Promise<ParseResult> {
|
||||
logger.info('Parsing file:', filePath)
|
||||
|
||||
@@ -186,18 +216,18 @@ async function parseFileSingle(
|
||||
}
|
||||
|
||||
if (filePath.includes('/api/files/serve/')) {
|
||||
return handleCloudFile(filePath, fileType, undefined, userId)
|
||||
return handleCloudFile(filePath, fileType, undefined, userId, executionContext)
|
||||
}
|
||||
|
||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||
return handleExternalUrl(filePath, fileType, workspaceId, userId)
|
||||
return handleExternalUrl(filePath, fileType, workspaceId, userId, executionContext)
|
||||
}
|
||||
|
||||
if (isUsingCloudStorage()) {
|
||||
return handleCloudFile(filePath, fileType, undefined, userId)
|
||||
return handleCloudFile(filePath, fileType, undefined, userId, executionContext)
|
||||
}
|
||||
|
||||
return handleLocalFile(filePath, fileType, userId)
|
||||
return handleLocalFile(filePath, fileType, userId, executionContext)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,12 +260,14 @@ function validateFilePath(filePath: string): { isValid: boolean; error?: string
|
||||
/**
|
||||
* Handle external URL
|
||||
* If workspaceId is provided, checks if file already exists and saves to workspace if not
|
||||
* If executionContext is provided, also stores the file in execution storage and returns UserFile
|
||||
*/
|
||||
async function handleExternalUrl(
|
||||
url: string,
|
||||
fileType: string,
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
executionContext?: ExecutionContext
|
||||
): Promise<ParseResult> {
|
||||
try {
|
||||
logger.info('Fetching external URL:', url)
|
||||
@@ -312,17 +344,13 @@ async function handleExternalUrl(
|
||||
|
||||
if (existingFile) {
|
||||
const storageFilePath = `/api/files/serve/${existingFile.key}`
|
||||
return handleCloudFile(storageFilePath, fileType, 'workspace', userId)
|
||||
return handleCloudFile(storageFilePath, fileType, 'workspace', userId, executionContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pinnedUrl = createPinnedUrl(url, urlValidation.resolvedIP!)
|
||||
const response = await fetch(pinnedUrl, {
|
||||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||
headers: {
|
||||
Host: urlValidation.originalHostname!,
|
||||
},
|
||||
const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, {
|
||||
timeout: DOWNLOAD_TIMEOUT_MS,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`)
|
||||
@@ -341,6 +369,19 @@ async function handleExternalUrl(
|
||||
|
||||
logger.info(`Downloaded file from URL: ${url}, size: ${buffer.length} bytes`)
|
||||
|
||||
let userFile: UserFile | undefined
|
||||
const mimeType = response.headers.get('content-type') || getMimeTypeFromExtension(extension)
|
||||
|
||||
if (executionContext) {
|
||||
try {
|
||||
userFile = await uploadExecutionFile(executionContext, buffer, filename, mimeType, userId)
|
||||
logger.info(`Stored file in execution storage: ${filename}`, { key: userFile.key })
|
||||
} catch (uploadError) {
|
||||
logger.warn(`Failed to store file in execution storage:`, uploadError)
|
||||
// Continue without userFile - parsing can still work
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldCheckWorkspace) {
|
||||
try {
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
@@ -353,8 +394,6 @@ async function handleExternalUrl(
|
||||
})
|
||||
} else {
|
||||
const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace')
|
||||
const mimeType =
|
||||
response.headers.get('content-type') || getMimeTypeFromExtension(extension)
|
||||
await uploadWorkspaceFile(workspaceId, userId, buffer, filename, mimeType)
|
||||
logger.info(`Saved URL file to workspace storage: ${filename}`)
|
||||
}
|
||||
@@ -363,17 +402,23 @@ async function handleExternalUrl(
|
||||
}
|
||||
}
|
||||
|
||||
let parseResult: ParseResult
|
||||
if (extension === 'pdf') {
|
||||
return await handlePdfBuffer(buffer, filename, fileType, url)
|
||||
}
|
||||
if (extension === 'csv') {
|
||||
return await handleCsvBuffer(buffer, filename, fileType, url)
|
||||
}
|
||||
if (isSupportedFileType(extension)) {
|
||||
return await handleGenericTextBuffer(buffer, filename, extension, fileType, url)
|
||||
parseResult = await handlePdfBuffer(buffer, filename, fileType, url)
|
||||
} else if (extension === 'csv') {
|
||||
parseResult = await handleCsvBuffer(buffer, filename, fileType, url)
|
||||
} else if (isSupportedFileType(extension)) {
|
||||
parseResult = await handleGenericTextBuffer(buffer, filename, extension, fileType, url)
|
||||
} else {
|
||||
parseResult = handleGenericBuffer(buffer, filename, extension, fileType)
|
||||
}
|
||||
|
||||
return handleGenericBuffer(buffer, filename, extension, fileType)
|
||||
// Attach userFile to the result
|
||||
if (userFile) {
|
||||
parseResult.userFile = userFile
|
||||
}
|
||||
|
||||
return parseResult
|
||||
} catch (error) {
|
||||
logger.error(`Error handling external URL ${url}:`, error)
|
||||
return {
|
||||
@@ -386,12 +431,15 @@ async function handleExternalUrl(
|
||||
|
||||
/**
|
||||
* Handle file stored in cloud storage
|
||||
* If executionContext is provided and file is not already from execution storage,
|
||||
* copies the file to execution storage and returns UserFile
|
||||
*/
|
||||
async function handleCloudFile(
|
||||
filePath: string,
|
||||
fileType: string,
|
||||
explicitContext: string | undefined,
|
||||
userId: string
|
||||
userId: string,
|
||||
executionContext?: ExecutionContext
|
||||
): Promise<ParseResult> {
|
||||
try {
|
||||
const cloudKey = extractStorageKey(filePath)
|
||||
@@ -438,6 +486,7 @@ async function handleCloudFile(
|
||||
|
||||
const filename = originalFilename || cloudKey.split('/').pop() || cloudKey
|
||||
const extension = path.extname(filename).toLowerCase().substring(1)
|
||||
const mimeType = getMimeTypeFromExtension(extension)
|
||||
|
||||
const normalizedFilePath = `/api/files/serve/${encodeURIComponent(cloudKey)}?context=${context}`
|
||||
let workspaceIdFromKey: string | undefined
|
||||
@@ -453,6 +502,39 @@ async function handleCloudFile(
|
||||
|
||||
const viewerUrl = getViewerUrl(cloudKey, workspaceIdFromKey)
|
||||
|
||||
// Store file in execution storage if executionContext is provided
|
||||
let userFile: UserFile | undefined
|
||||
|
||||
if (executionContext) {
|
||||
// If file is already from execution context, create UserFile reference without re-uploading
|
||||
if (context === 'execution') {
|
||||
userFile = {
|
||||
id: `file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||
name: filename,
|
||||
url: normalizedFilePath,
|
||||
size: fileBuffer.length,
|
||||
type: mimeType,
|
||||
key: cloudKey,
|
||||
context: 'execution',
|
||||
}
|
||||
logger.info(`Created UserFile reference for existing execution file: ${filename}`)
|
||||
} else {
|
||||
// Copy from workspace/other storage to execution storage
|
||||
try {
|
||||
userFile = await uploadExecutionFile(
|
||||
executionContext,
|
||||
fileBuffer,
|
||||
filename,
|
||||
mimeType,
|
||||
userId
|
||||
)
|
||||
logger.info(`Copied file to execution storage: ${filename}`, { key: userFile.key })
|
||||
} catch (uploadError) {
|
||||
logger.warn(`Failed to copy file to execution storage:`, uploadError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let parseResult: ParseResult
|
||||
if (extension === 'pdf') {
|
||||
parseResult = await handlePdfBuffer(fileBuffer, filename, fileType, normalizedFilePath)
|
||||
@@ -477,6 +559,11 @@ async function handleCloudFile(
|
||||
|
||||
parseResult.viewerUrl = viewerUrl
|
||||
|
||||
// Attach userFile to the result
|
||||
if (userFile) {
|
||||
parseResult.userFile = userFile
|
||||
}
|
||||
|
||||
return parseResult
|
||||
} catch (error) {
|
||||
logger.error(`Error handling cloud file ${filePath}:`, error)
|
||||
@@ -500,7 +587,8 @@ async function handleCloudFile(
|
||||
async function handleLocalFile(
|
||||
filePath: string,
|
||||
fileType: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
executionContext?: ExecutionContext
|
||||
): Promise<ParseResult> {
|
||||
try {
|
||||
const filename = filePath.split('/').pop() || filePath
|
||||
@@ -540,13 +628,32 @@ async function handleLocalFile(
|
||||
const hash = createHash('md5').update(fileBuffer).digest('hex')
|
||||
|
||||
const extension = path.extname(filename).toLowerCase().substring(1)
|
||||
const mimeType = fileType || getMimeTypeFromExtension(extension)
|
||||
|
||||
// Store file in execution storage if executionContext is provided
|
||||
let userFile: UserFile | undefined
|
||||
if (executionContext) {
|
||||
try {
|
||||
userFile = await uploadExecutionFile(
|
||||
executionContext,
|
||||
fileBuffer,
|
||||
filename,
|
||||
mimeType,
|
||||
userId
|
||||
)
|
||||
logger.info(`Stored local file in execution storage: ${filename}`, { key: userFile.key })
|
||||
} catch (uploadError) {
|
||||
logger.warn(`Failed to store local file in execution storage:`, uploadError)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: result.content,
|
||||
filePath,
|
||||
userFile,
|
||||
metadata: {
|
||||
fileType: fileType || getMimeTypeFromExtension(extension),
|
||||
fileType: mimeType,
|
||||
size: stats.size,
|
||||
hash,
|
||||
processingTime: 0,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
|
||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -36,7 +36,7 @@ async function getWorkflowInputSchema(workflowId: string): Promise<any[]> {
|
||||
.from(workflowBlocks)
|
||||
.where(eq(workflowBlocks.workflowId, workflowId))
|
||||
|
||||
const startBlock = blocks.find((block) => isValidStartBlockType(block.type))
|
||||
const startBlock = blocks.find((block) => isInputDefinitionTrigger(block.type))
|
||||
|
||||
if (!startBlock) {
|
||||
return []
|
||||
|
||||
@@ -276,8 +276,11 @@ describe('Function Execute API Route', () => {
|
||||
it.concurrent('should resolve tag variables with <tag_name> syntax', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <email>',
|
||||
params: {
|
||||
email: { id: '123', subject: 'Test Email' },
|
||||
blockData: {
|
||||
'block-123': { id: '123', subject: 'Test Email' },
|
||||
},
|
||||
blockNameMapping: {
|
||||
email: 'block-123',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -305,9 +308,13 @@ describe('Function Execute API Route', () => {
|
||||
it.concurrent('should only match valid variable names in angle brackets', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <validVar> + "<invalid@email.com>" + <another_valid>',
|
||||
params: {
|
||||
validVar: 'hello',
|
||||
another_valid: 'world',
|
||||
blockData: {
|
||||
'block-1': 'hello',
|
||||
'block-2': 'world',
|
||||
},
|
||||
blockNameMapping: {
|
||||
validVar: 'block-1',
|
||||
another_valid: 'block-2',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -321,28 +328,22 @@ describe('Function Execute API Route', () => {
|
||||
it.concurrent(
|
||||
'should handle Gmail webhook data with email addresses containing angle brackets',
|
||||
async () => {
|
||||
const gmailData = {
|
||||
email: {
|
||||
id: '123',
|
||||
from: 'Waleed Latif <waleed@sim.ai>',
|
||||
to: 'User <user@example.com>',
|
||||
subject: 'Test Email',
|
||||
bodyText: 'Hello world',
|
||||
},
|
||||
rawEmail: {
|
||||
id: '123',
|
||||
payload: {
|
||||
headers: [
|
||||
{ name: 'From', value: 'Waleed Latif <waleed@sim.ai>' },
|
||||
{ name: 'To', value: 'User <user@example.com>' },
|
||||
],
|
||||
},
|
||||
},
|
||||
const emailData = {
|
||||
id: '123',
|
||||
from: 'Waleed Latif <waleed@sim.ai>',
|
||||
to: 'User <user@example.com>',
|
||||
subject: 'Test Email',
|
||||
bodyText: 'Hello world',
|
||||
}
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <email>',
|
||||
params: gmailData,
|
||||
blockData: {
|
||||
'block-email': emailData,
|
||||
},
|
||||
blockNameMapping: {
|
||||
email: 'block-email',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
@@ -356,17 +357,20 @@ describe('Function Execute API Route', () => {
|
||||
it.concurrent(
|
||||
'should properly serialize complex email objects with special characters',
|
||||
async () => {
|
||||
const complexEmailData = {
|
||||
email: {
|
||||
from: 'Test User <test@example.com>',
|
||||
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
|
||||
bodyText: 'Text with\nnewlines\tand\ttabs',
|
||||
},
|
||||
const emailData = {
|
||||
from: 'Test User <test@example.com>',
|
||||
bodyHtml: '<div>HTML content with "quotes" and \'apostrophes\'</div>',
|
||||
bodyText: 'Text with\nnewlines\tand\ttabs',
|
||||
}
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <email>',
|
||||
params: complexEmailData,
|
||||
blockData: {
|
||||
'block-email': emailData,
|
||||
},
|
||||
blockNameMapping: {
|
||||
email: 'block-email',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
@@ -519,18 +523,23 @@ describe('Function Execute API Route', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should handle JSON serialization edge cases', async () => {
|
||||
const complexData = {
|
||||
special: 'chars"with\'quotes',
|
||||
unicode: '🎉 Unicode content',
|
||||
nested: {
|
||||
deep: {
|
||||
value: 'test',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <complexData>',
|
||||
params: {
|
||||
complexData: {
|
||||
special: 'chars"with\'quotes',
|
||||
unicode: '🎉 Unicode content',
|
||||
nested: {
|
||||
deep: {
|
||||
value: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
blockData: {
|
||||
'block-complex': complexData,
|
||||
},
|
||||
blockNameMapping: {
|
||||
complexData: 'block-complex',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
|
||||
import {
|
||||
createEnvVarPattern,
|
||||
createWorkflowVariablePattern,
|
||||
resolveEnvVarReferences,
|
||||
} from '@/executor/utils/reference-validation'
|
||||
import { navigatePath } from '@/executor/variables/resolvers/reference'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
@@ -18,8 +18,8 @@ export const MAX_DURATION = 210
|
||||
|
||||
const logger = createLogger('FunctionExecuteAPI')
|
||||
|
||||
const E2B_JS_WRAPPER_LINES = 3 // Lines before user code: ';(async () => {', ' try {', ' const __sim_result = await (async () => {'
|
||||
const E2B_PYTHON_WRAPPER_LINES = 1 // Lines before user code: 'def __sim_main__():'
|
||||
const E2B_JS_WRAPPER_LINES = 3
|
||||
const E2B_PYTHON_WRAPPER_LINES = 1
|
||||
|
||||
type TypeScriptModule = typeof import('typescript')
|
||||
|
||||
@@ -134,33 +134,21 @@ function extractEnhancedError(
|
||||
if (error.stack) {
|
||||
enhanced.stack = error.stack
|
||||
|
||||
// Parse stack trace to extract line and column information
|
||||
// Handle both compilation errors and runtime errors
|
||||
const stackLines: string[] = error.stack.split('\n')
|
||||
|
||||
for (const line of stackLines) {
|
||||
// Pattern 1: Compilation errors - "user-function.js:6"
|
||||
let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
||||
|
||||
// Pattern 2: Runtime errors - "at user-function.js:5:12"
|
||||
if (!match) {
|
||||
match = line.match(/at\s+user-function\.js:(\d+):(\d+)/)
|
||||
}
|
||||
|
||||
// Pattern 3: Generic patterns for any line containing our filename
|
||||
if (!match) {
|
||||
match = line.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
||||
}
|
||||
|
||||
if (match) {
|
||||
const stackLine = Number.parseInt(match[1], 10)
|
||||
const stackColumn = match[2] ? Number.parseInt(match[2], 10) : undefined
|
||||
|
||||
// Adjust line number to account for wrapper code
|
||||
// The user code starts at a specific line in our wrapper
|
||||
const adjustedLine = stackLine - userCodeStartLine + 1
|
||||
|
||||
// Check if this is a syntax error in wrapper code caused by incomplete user code
|
||||
const isWrapperSyntaxError =
|
||||
stackLine > userCodeStartLine &&
|
||||
error.name === 'SyntaxError' &&
|
||||
@@ -168,7 +156,6 @@ function extractEnhancedError(
|
||||
error.message.includes('Unexpected end of input'))
|
||||
|
||||
if (isWrapperSyntaxError && userCode) {
|
||||
// Map wrapper syntax errors to the last line of user code
|
||||
const codeLines = userCode.split('\n')
|
||||
const lastUserLine = codeLines.length
|
||||
enhanced.line = lastUserLine
|
||||
@@ -181,7 +168,6 @@ function extractEnhancedError(
|
||||
enhanced.line = adjustedLine
|
||||
enhanced.column = stackColumn
|
||||
|
||||
// Extract the actual line content from user code
|
||||
if (userCode) {
|
||||
const codeLines = userCode.split('\n')
|
||||
if (adjustedLine <= codeLines.length) {
|
||||
@@ -192,7 +178,6 @@ function extractEnhancedError(
|
||||
}
|
||||
|
||||
if (stackLine <= userCodeStartLine) {
|
||||
// Error is in wrapper code itself
|
||||
enhanced.line = stackLine
|
||||
enhanced.column = stackColumn
|
||||
break
|
||||
@@ -200,7 +185,6 @@ function extractEnhancedError(
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stack trace to show user-relevant information
|
||||
const cleanedStackLines: string[] = stackLines
|
||||
.filter(
|
||||
(line: string) =>
|
||||
@@ -214,9 +198,6 @@ function extractEnhancedError(
|
||||
}
|
||||
}
|
||||
|
||||
// Keep original message without adding error type prefix
|
||||
// The error type will be added later in createUserFriendlyErrorMessage
|
||||
|
||||
return enhanced
|
||||
}
|
||||
|
||||
@@ -231,7 +212,6 @@ function formatE2BError(
|
||||
userCode: string,
|
||||
prologueLineCount: number
|
||||
): { formattedError: string; cleanedOutput: string } {
|
||||
// Calculate line offset based on language and prologue
|
||||
const wrapperLines =
|
||||
language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES
|
||||
const totalOffset = prologueLineCount + wrapperLines
|
||||
@@ -241,27 +221,20 @@ function formatE2BError(
|
||||
let cleanErrorMsg = ''
|
||||
|
||||
if (language === CodeLanguage.Python) {
|
||||
// Python error format: "Cell In[X], line Y" followed by error details
|
||||
// Extract line number from the Cell reference
|
||||
const cellMatch = errorOutput.match(/Cell In\[\d+\], line (\d+)/)
|
||||
if (cellMatch) {
|
||||
const originalLine = Number.parseInt(cellMatch[1], 10)
|
||||
userLine = originalLine - totalOffset
|
||||
}
|
||||
|
||||
// Extract clean error message from the error string
|
||||
// Remove file references like "(detected at line X) (file.py, line Y)"
|
||||
cleanErrorMsg = errorMessage
|
||||
.replace(/\s*\(detected at line \d+\)/g, '')
|
||||
.replace(/\s*\([^)]+\.py, line \d+\)/g, '')
|
||||
.trim()
|
||||
} else if (language === CodeLanguage.JavaScript) {
|
||||
// JavaScript error format from E2B: "SyntaxError: /path/file.ts: Message. (line:col)\n\n 9 | ..."
|
||||
// First, extract the error type and message from the first line
|
||||
const firstLineEnd = errorMessage.indexOf('\n')
|
||||
const firstLine = firstLineEnd > 0 ? errorMessage.substring(0, firstLineEnd) : errorMessage
|
||||
|
||||
// Parse: "SyntaxError: /home/user/index.ts: Missing semicolon. (11:9)"
|
||||
const jsErrorMatch = firstLine.match(/^(\w+Error):\s*[^:]+:\s*([^(]+)\.\s*\((\d+):(\d+)\)/)
|
||||
if (jsErrorMatch) {
|
||||
cleanErrorType = jsErrorMatch[1]
|
||||
@@ -269,13 +242,11 @@ function formatE2BError(
|
||||
const originalLine = Number.parseInt(jsErrorMatch[3], 10)
|
||||
userLine = originalLine - totalOffset
|
||||
} else {
|
||||
// Fallback: look for line number in the arrow pointer line (> 11 |)
|
||||
const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m)
|
||||
if (arrowMatch) {
|
||||
const originalLine = Number.parseInt(arrowMatch[1], 10)
|
||||
userLine = originalLine - totalOffset
|
||||
}
|
||||
// Try to extract error type and message
|
||||
const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/)
|
||||
if (errorMatch) {
|
||||
cleanErrorType = errorMatch[1]
|
||||
@@ -289,13 +260,11 @@ function formatE2BError(
|
||||
}
|
||||
}
|
||||
|
||||
// Build the final clean error message
|
||||
const finalErrorMsg =
|
||||
cleanErrorType && cleanErrorMsg
|
||||
? `${cleanErrorType}: ${cleanErrorMsg}`
|
||||
: cleanErrorMsg || errorMessage
|
||||
|
||||
// Format with line number if available
|
||||
let formattedError = finalErrorMsg
|
||||
if (userLine && userLine > 0) {
|
||||
const codeLines = userCode.split('\n')
|
||||
@@ -311,7 +280,6 @@ function formatE2BError(
|
||||
}
|
||||
}
|
||||
|
||||
// For stdout, just return the clean error message without the full traceback
|
||||
const cleanedOutput = finalErrorMsg
|
||||
|
||||
return { formattedError, cleanedOutput }
|
||||
@@ -327,7 +295,6 @@ function createUserFriendlyErrorMessage(
|
||||
): string {
|
||||
let errorMessage = enhanced.message
|
||||
|
||||
// Add line information if available
|
||||
if (enhanced.line !== undefined) {
|
||||
let lineInfo = `Line ${enhanced.line}`
|
||||
|
||||
@@ -338,18 +305,14 @@ function createUserFriendlyErrorMessage(
|
||||
|
||||
errorMessage = `${lineInfo} - ${errorMessage}`
|
||||
} else {
|
||||
// If no line number, try to extract it from stack trace for display
|
||||
if (enhanced.stack) {
|
||||
const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/)
|
||||
if (stackMatch) {
|
||||
const line = Number.parseInt(stackMatch[1], 10)
|
||||
let lineInfo = `Line ${line}`
|
||||
|
||||
// Try to get line content if we have userCode
|
||||
if (userCode) {
|
||||
const codeLines = userCode.split('\n')
|
||||
// Note: stackMatch gives us VM line number, need to adjust
|
||||
// This is a fallback case, so we might not have perfect line mapping
|
||||
if (line <= codeLines.length) {
|
||||
const lineContent = codeLines[line - 1]?.trim()
|
||||
if (lineContent) {
|
||||
@@ -363,7 +326,6 @@ function createUserFriendlyErrorMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Add error type prefix with consistent naming
|
||||
if (enhanced.name !== 'Error') {
|
||||
const errorTypePrefix =
|
||||
enhanced.name === 'SyntaxError'
|
||||
@@ -374,7 +336,6 @@ function createUserFriendlyErrorMessage(
|
||||
? 'Reference Error'
|
||||
: enhanced.name
|
||||
|
||||
// Only add prefix if not already present
|
||||
if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) {
|
||||
errorMessage = `${errorTypePrefix}: ${errorMessage}`
|
||||
}
|
||||
@@ -383,9 +344,6 @@ function createUserFriendlyErrorMessage(
|
||||
return errorMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves workflow variables with <variable.name> syntax
|
||||
*/
|
||||
function resolveWorkflowVariables(
|
||||
code: string,
|
||||
workflowVariables: Record<string, any>,
|
||||
@@ -405,39 +363,35 @@ function resolveWorkflowVariables(
|
||||
while ((match = regex.exec(code)) !== null) {
|
||||
const variableName = match[1].trim()
|
||||
|
||||
// Find the variable by name (workflowVariables is indexed by ID, values are variable objects)
|
||||
const foundVariable = Object.entries(workflowVariables).find(
|
||||
([_, variable]) => normalizeName(variable.name || '') === variableName
|
||||
)
|
||||
|
||||
let variableValue: unknown = ''
|
||||
if (foundVariable) {
|
||||
const variable = foundVariable[1]
|
||||
variableValue = variable.value
|
||||
if (!foundVariable) {
|
||||
const availableVars = Object.values(workflowVariables)
|
||||
.map((v) => v.name)
|
||||
.filter(Boolean)
|
||||
throw new Error(
|
||||
`Variable "${variableName}" doesn't exist.` +
|
||||
(availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '')
|
||||
)
|
||||
}
|
||||
|
||||
if (variable.value !== undefined && variable.value !== null) {
|
||||
const variable = foundVariable[1]
|
||||
let variableValue: unknown = variable.value
|
||||
|
||||
if (variable.value !== undefined && variable.value !== null) {
|
||||
const type = variable.type === 'string' ? 'plain' : variable.type
|
||||
|
||||
if (type === 'number') {
|
||||
variableValue = Number(variableValue)
|
||||
} else if (type === 'boolean') {
|
||||
variableValue = variableValue === 'true' || variableValue === true
|
||||
} else if (type === 'json' && typeof variableValue === 'string') {
|
||||
try {
|
||||
// Handle 'string' type the same as 'plain' for backward compatibility
|
||||
const type = variable.type === 'string' ? 'plain' : variable.type
|
||||
|
||||
// For plain text, use exactly what's entered without modifications
|
||||
if (type === 'plain' && typeof variableValue === 'string') {
|
||||
// Use as-is for plain text
|
||||
} else if (type === 'number') {
|
||||
variableValue = Number(variableValue)
|
||||
} else if (type === 'boolean') {
|
||||
variableValue = variableValue === 'true' || variableValue === true
|
||||
} else if (type === 'json') {
|
||||
try {
|
||||
variableValue =
|
||||
typeof variableValue === 'string' ? JSON.parse(variableValue) : variableValue
|
||||
} catch {
|
||||
// Keep original value if JSON parsing fails
|
||||
}
|
||||
}
|
||||
variableValue = JSON.parse(variableValue)
|
||||
} catch {
|
||||
// Fallback to original value on error
|
||||
variableValue = variable.value
|
||||
// Keep as-is
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,11 +404,9 @@ function resolveWorkflowVariables(
|
||||
})
|
||||
}
|
||||
|
||||
// Process replacements in reverse order to maintain correct indices
|
||||
for (let i = replacements.length - 1; i >= 0; i--) {
|
||||
const { match: matchStr, index, variableName, variableValue } = replacements[i]
|
||||
|
||||
// Use variable reference approach
|
||||
const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||
contextVariables[safeVarName] = variableValue
|
||||
resolvedCode =
|
||||
@@ -464,9 +416,6 @@ function resolveWorkflowVariables(
|
||||
return resolvedCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves environment variables with {{var_name}} syntax
|
||||
*/
|
||||
function resolveEnvironmentVariables(
|
||||
code: string,
|
||||
params: Record<string, any>,
|
||||
@@ -482,32 +431,28 @@ function resolveEnvironmentVariables(
|
||||
|
||||
const resolverVars: Record<string, string> = {}
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
if (value !== undefined && value !== null) {
|
||||
resolverVars[key] = String(value)
|
||||
}
|
||||
})
|
||||
Object.entries(envVars).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
if (value !== undefined && value !== null) {
|
||||
resolverVars[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
while ((match = regex.exec(code)) !== null) {
|
||||
const varName = match[1].trim()
|
||||
const resolved = resolveEnvVarReferences(match[0], resolverVars, {
|
||||
allowEmbedded: true,
|
||||
resolveExactMatch: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'empty',
|
||||
deep: false,
|
||||
})
|
||||
const varValue =
|
||||
typeof resolved === 'string' ? resolved : resolved == null ? '' : String(resolved)
|
||||
|
||||
if (!(varName in resolverVars)) {
|
||||
continue
|
||||
}
|
||||
|
||||
replacements.push({
|
||||
match: match[0],
|
||||
index: match.index,
|
||||
varName,
|
||||
varValue: String(varValue),
|
||||
varValue: resolverVars[varName],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -523,12 +468,8 @@ function resolveEnvironmentVariables(
|
||||
return resolvedCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves tags with <tag_name> syntax (including nested paths like <block.response.data>)
|
||||
*/
|
||||
function resolveTagVariables(
|
||||
code: string,
|
||||
params: Record<string, any>,
|
||||
blockData: Record<string, any>,
|
||||
blockNameMapping: Record<string, string>,
|
||||
contextVariables: Record<string, any>
|
||||
@@ -543,27 +484,30 @@ function resolveTagVariables(
|
||||
|
||||
for (const match of tagMatches) {
|
||||
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
|
||||
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
||||
const blockName = pathParts[0]
|
||||
|
||||
// Handle nested paths like "getrecord.response.data" or "function1.response.result"
|
||||
// First try params, then blockData directly, then try with block name mapping
|
||||
let tagValue = getNestedValue(params, tagName) || getNestedValue(blockData, tagName) || ''
|
||||
|
||||
// If not found and the path starts with a block name, try mapping the block name to ID
|
||||
if (!tagValue && tagName.includes(REFERENCE.PATH_DELIMITER)) {
|
||||
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
||||
const normalizedBlockName = pathParts[0] // This should already be normalized like "function1"
|
||||
|
||||
// Direct lookup using normalized block name
|
||||
const blockId = blockNameMapping[normalizedBlockName] ?? null
|
||||
|
||||
if (blockId) {
|
||||
const remainingPath = pathParts.slice(1).join('.')
|
||||
const fullPath = `${blockId}.${remainingPath}`
|
||||
tagValue = getNestedValue(blockData, fullPath) || ''
|
||||
}
|
||||
const blockId = blockNameMapping[blockName]
|
||||
if (!blockId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const blockOutput = blockData[blockId]
|
||||
if (blockOutput === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
let tagValue: any
|
||||
if (pathParts.length === 1) {
|
||||
tagValue = blockOutput
|
||||
} else {
|
||||
tagValue = navigatePath(blockOutput, pathParts.slice(1))
|
||||
}
|
||||
|
||||
if (tagValue === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the value is a stringified JSON, parse it back to object
|
||||
if (
|
||||
typeof tagValue === 'string' &&
|
||||
tagValue.length > 100 &&
|
||||
@@ -571,16 +515,13 @@ function resolveTagVariables(
|
||||
) {
|
||||
try {
|
||||
tagValue = JSON.parse(tagValue)
|
||||
} catch (e) {
|
||||
// Keep as string if parsing fails
|
||||
} catch {
|
||||
// Keep as-is
|
||||
}
|
||||
}
|
||||
|
||||
// Instead of injecting large JSON directly, create a variable reference
|
||||
const safeVarName = `__tag_${tagName.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||
contextVariables[safeVarName] = tagValue
|
||||
|
||||
// Replace the template with a variable reference
|
||||
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
|
||||
}
|
||||
|
||||
@@ -605,35 +546,13 @@ function resolveCodeVariables(
|
||||
let resolvedCode = code
|
||||
const contextVariables: Record<string, any> = {}
|
||||
|
||||
// Resolve workflow variables with <variable.name> syntax first
|
||||
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
|
||||
|
||||
// Resolve environment variables with {{var_name}} syntax
|
||||
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
|
||||
|
||||
// Resolve tags with <tag_name> syntax (including nested paths like <block.response.data>)
|
||||
resolvedCode = resolveTagVariables(
|
||||
resolvedCode,
|
||||
params,
|
||||
blockData,
|
||||
blockNameMapping,
|
||||
contextVariables
|
||||
)
|
||||
resolvedCode = resolveTagVariables(resolvedCode, blockData, blockNameMapping, contextVariables)
|
||||
|
||||
return { resolvedCode, contextVariables }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested value from object using dot notation path
|
||||
*/
|
||||
function getNestedValue(obj: any, path: string): any {
|
||||
if (!obj || !path) return undefined
|
||||
|
||||
return path.split('.').reduce((current, key) => {
|
||||
return current && typeof current === 'object' ? current[key] : undefined
|
||||
}, obj)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one trailing newline from stdout
|
||||
* This handles the common case where print() or console.log() adds a trailing \n
|
||||
@@ -671,7 +590,6 @@ export async function POST(req: NextRequest) {
|
||||
isCustomTool = false,
|
||||
} = body
|
||||
|
||||
// Extract internal parameters that shouldn't be passed to the execution context
|
||||
const executionParams = { ...params }
|
||||
executionParams._context = undefined
|
||||
|
||||
@@ -697,7 +615,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
||||
|
||||
// Extract imports once for JavaScript code (reuse later to avoid double extraction)
|
||||
let jsImports = ''
|
||||
let jsRemainingCode = resolvedCode
|
||||
let hasImports = false
|
||||
@@ -707,31 +624,22 @@ export async function POST(req: NextRequest) {
|
||||
jsImports = extractionResult.imports
|
||||
jsRemainingCode = extractionResult.remainingCode
|
||||
|
||||
// Check for ES6 imports or CommonJS require statements
|
||||
// ES6 imports are extracted by the TypeScript parser
|
||||
// Also check for require() calls which indicate external dependencies
|
||||
const hasRequireStatements = /require\s*\(\s*['"`]/.test(resolvedCode)
|
||||
hasImports = jsImports.trim().length > 0 || hasRequireStatements
|
||||
}
|
||||
|
||||
// Python always requires E2B
|
||||
if (lang === CodeLanguage.Python && !isE2bEnabled) {
|
||||
throw new Error(
|
||||
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.'
|
||||
)
|
||||
}
|
||||
|
||||
// JavaScript with imports requires E2B
|
||||
if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) {
|
||||
throw new Error(
|
||||
'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.'
|
||||
)
|
||||
}
|
||||
|
||||
// Use E2B if:
|
||||
// - E2B is enabled AND
|
||||
// - Not a custom tool AND
|
||||
// - (Python OR JavaScript with imports)
|
||||
const useE2B =
|
||||
isE2bEnabled &&
|
||||
!isCustomTool &&
|
||||
@@ -744,13 +652,10 @@ export async function POST(req: NextRequest) {
|
||||
language: lang,
|
||||
})
|
||||
let prologue = ''
|
||||
const epilogue = ''
|
||||
|
||||
if (lang === CodeLanguage.JavaScript) {
|
||||
// Track prologue lines for error adjustment
|
||||
let prologueLineCount = 0
|
||||
|
||||
// Reuse the imports we already extracted earlier
|
||||
const imports = jsImports
|
||||
const remainingCode = jsRemainingCode
|
||||
|
||||
@@ -782,7 +687,7 @@ export async function POST(req: NextRequest) {
|
||||
' }',
|
||||
'})();',
|
||||
].join('\n')
|
||||
const codeForE2B = importSection + prologue + wrapped + epilogue
|
||||
const codeForE2B = importSection + prologue + wrapped
|
||||
|
||||
const execStart = Date.now()
|
||||
const {
|
||||
@@ -804,7 +709,6 @@ export async function POST(req: NextRequest) {
|
||||
error: e2bError,
|
||||
})
|
||||
|
||||
// If there was an execution error, format it properly
|
||||
if (e2bError) {
|
||||
const { formattedError, cleanedOutput } = formatE2BError(
|
||||
e2bError,
|
||||
@@ -828,7 +732,7 @@ export async function POST(req: NextRequest) {
|
||||
output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime },
|
||||
})
|
||||
}
|
||||
// Track prologue lines for error adjustment
|
||||
|
||||
let prologueLineCount = 0
|
||||
prologue += 'import json\n'
|
||||
prologueLineCount++
|
||||
@@ -846,7 +750,7 @@ export async function POST(req: NextRequest) {
|
||||
'__sim_result__ = __sim_main__()',
|
||||
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
|
||||
].join('\n')
|
||||
const codeForE2B = prologue + wrapped + epilogue
|
||||
const codeForE2B = prologue + wrapped
|
||||
|
||||
const execStart = Date.now()
|
||||
const {
|
||||
@@ -868,7 +772,6 @@ export async function POST(req: NextRequest) {
|
||||
error: e2bError,
|
||||
})
|
||||
|
||||
// If there was an execution error, format it properly
|
||||
if (e2bError) {
|
||||
const { formattedError, cleanedOutput } = formatE2BError(
|
||||
e2bError,
|
||||
@@ -897,7 +800,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const wrapperLines = ['(async () => {', ' try {']
|
||||
if (isCustomTool) {
|
||||
wrapperLines.push(' // For custom tools, make parameters directly accessible')
|
||||
Object.keys(executionParams).forEach((key) => {
|
||||
wrapperLines.push(` const ${key} = params.${key};`)
|
||||
})
|
||||
@@ -931,12 +833,10 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
|
||||
const ivmError = isolatedResult.error
|
||||
// Adjust line number for prepended param destructuring in custom tools
|
||||
let adjustedLine = ivmError.line
|
||||
let adjustedLineContent = ivmError.lineContent
|
||||
if (prependedLineCount > 0 && ivmError.line !== undefined) {
|
||||
adjustedLine = Math.max(1, ivmError.line - prependedLineCount)
|
||||
// Get line content from original user code, not the prepended code
|
||||
const codeLines = resolvedCode.split('\n')
|
||||
if (adjustedLine <= codeLines.length) {
|
||||
adjustedLineContent = codeLines[adjustedLine - 1]?.trim()
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { executeTool } from '@/tools'
|
||||
import { getTool, validateRequiredParametersAfterMerge } from '@/tools/utils'
|
||||
|
||||
const logger = createLogger('ProxyAPI')
|
||||
|
||||
const proxyPostSchema = z.object({
|
||||
toolId: z.string().min(1, 'toolId is required'),
|
||||
params: z.record(z.any()).optional().default({}),
|
||||
executionContext: z
|
||||
.object({
|
||||
workflowId: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
executionId: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates a minimal set of default headers for proxy requests
|
||||
* @returns Record of HTTP headers
|
||||
*/
|
||||
const getProxyHeaders = (): Record<string, string> => {
|
||||
return {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
|
||||
Accept: '*/*',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a response with CORS headers
|
||||
* @param responseData Response data object
|
||||
* @param status HTTP status code
|
||||
* @returns NextResponse with CORS headers
|
||||
*/
|
||||
const formatResponse = (responseData: any, status = 200) => {
|
||||
return NextResponse.json(responseData, {
|
||||
status,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an error response with consistent formatting
|
||||
* @param error Error object or message
|
||||
* @param status HTTP status code
|
||||
* @param additionalData Additional data to include in the response
|
||||
* @returns Formatted error response
|
||||
*/
|
||||
const createErrorResponse = (error: any, status = 500, additionalData = {}) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const errorStack = error instanceof Error ? error.stack : undefined
|
||||
|
||||
logger.error('Creating error response', {
|
||||
errorMessage,
|
||||
status,
|
||||
stack: isDev ? errorStack : undefined,
|
||||
})
|
||||
|
||||
return formatResponse(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
stack: isDev ? errorStack : undefined,
|
||||
...additionalData,
|
||||
},
|
||||
status
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET handler for direct external URL proxying
|
||||
* This allows for GET requests to external APIs
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const targetUrl = url.searchParams.get('url')
|
||||
const requestId = generateRequestId()
|
||||
|
||||
// Vault download proxy: /api/proxy?vaultDownload=1&bucket=...&object=...&credentialId=...
|
||||
const vaultDownload = url.searchParams.get('vaultDownload')
|
||||
if (vaultDownload === '1') {
|
||||
try {
|
||||
const bucket = url.searchParams.get('bucket')
|
||||
const objectParam = url.searchParams.get('object')
|
||||
const credentialId = url.searchParams.get('credentialId')
|
||||
|
||||
if (!bucket || !objectParam || !credentialId) {
|
||||
return createErrorResponse('Missing bucket, object, or credentialId', 400)
|
||||
}
|
||||
|
||||
// Fetch access token using existing token API
|
||||
const baseUrl = new URL(getBaseUrl())
|
||||
const tokenUrl = new URL('/api/auth/oauth/token', baseUrl)
|
||||
|
||||
// Build headers: forward session cookies if present; include internal auth for server-side
|
||||
const tokenHeaders: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const incomingCookie = request.headers.get('cookie')
|
||||
if (incomingCookie) tokenHeaders.Cookie = incomingCookie
|
||||
try {
|
||||
const internalToken = await generateInternalToken()
|
||||
tokenHeaders.Authorization = `Bearer ${internalToken}`
|
||||
} catch (_e) {
|
||||
// best-effort internal auth
|
||||
}
|
||||
|
||||
// Optional workflow context for collaboration auth
|
||||
const workflowId = url.searchParams.get('workflowId') || undefined
|
||||
|
||||
const tokenRes = await fetch(tokenUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: tokenHeaders,
|
||||
body: JSON.stringify({ credentialId, workflowId }),
|
||||
})
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
const err = await tokenRes.text()
|
||||
return createErrorResponse(`Failed to fetch access token: ${err}`, 401)
|
||||
}
|
||||
|
||||
const tokenJson = await tokenRes.json()
|
||||
const accessToken = tokenJson.accessToken
|
||||
if (!accessToken) {
|
||||
return createErrorResponse('No access token available', 401)
|
||||
}
|
||||
|
||||
// Avoid double-encoding: incoming object may already be percent-encoded
|
||||
const objectDecoded = decodeURIComponent(objectParam)
|
||||
const gcsUrl = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(
|
||||
bucket
|
||||
)}/o/${encodeURIComponent(objectDecoded)}?alt=media`
|
||||
|
||||
const fileRes = await fetch(gcsUrl, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
if (!fileRes.ok) {
|
||||
const errText = await fileRes.text()
|
||||
return createErrorResponse(errText || 'Failed to download file', fileRes.status)
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
fileRes.headers.forEach((v, k) => headers.set(k, v))
|
||||
return new NextResponse(fileRes.body, { status: 200, headers })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Vault download proxy failed`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return createErrorResponse('Vault download failed', 500)
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetUrl) {
|
||||
logger.error(`[${requestId}] Missing 'url' parameter`)
|
||||
return createErrorResponse("Missing 'url' parameter", 400)
|
||||
}
|
||||
|
||||
const urlValidation = await validateUrlWithDNS(targetUrl)
|
||||
if (!urlValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Blocked proxy request`, {
|
||||
url: targetUrl.substring(0, 100),
|
||||
error: urlValidation.error,
|
||||
})
|
||||
return createErrorResponse(urlValidation.error || 'Invalid URL', 403)
|
||||
}
|
||||
|
||||
const method = url.searchParams.get('method') || 'GET'
|
||||
|
||||
const bodyParam = url.searchParams.get('body')
|
||||
let body: string | undefined
|
||||
|
||||
if (bodyParam && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
|
||||
try {
|
||||
body = decodeURIComponent(bodyParam)
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Failed to decode body parameter`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const customHeaders: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of url.searchParams.entries()) {
|
||||
if (key.startsWith('header.')) {
|
||||
const headerName = key.substring(7)
|
||||
customHeaders[headerName] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (body && !customHeaders['Content-Type']) {
|
||||
customHeaders['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Proxying ${method} request to: ${targetUrl}`)
|
||||
|
||||
try {
|
||||
const pinnedUrl = createPinnedUrl(targetUrl, urlValidation.resolvedIP!)
|
||||
const response = await fetch(pinnedUrl, {
|
||||
method: method,
|
||||
headers: {
|
||||
...getProxyHeaders(),
|
||||
...customHeaders,
|
||||
Host: urlValidation.originalHostname!,
|
||||
},
|
||||
body: body || undefined,
|
||||
})
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
let data
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
data = await response.json()
|
||||
} else {
|
||||
data = await response.text()
|
||||
}
|
||||
|
||||
const errorMessage = !response.ok
|
||||
? data && typeof data === 'object' && data.error
|
||||
? `${data.error.message || JSON.stringify(data.error)}`
|
||||
: response.statusText || `HTTP error ${response.status}`
|
||||
: undefined
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`[${requestId}] External API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return formatResponse({
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
data,
|
||||
error: errorMessage,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Proxy GET request failed`, {
|
||||
url: targetUrl,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
const startTime = new Date()
|
||||
const startTimeISO = startTime.toISOString()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
logger.error(`[${requestId}] Authentication failed for proxy:`, authResult.error)
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
let requestBody
|
||||
try {
|
||||
requestBody = await request.json()
|
||||
} catch (parseError) {
|
||||
logger.error(`[${requestId}] Failed to parse request body`, {
|
||||
error: parseError instanceof Error ? parseError.message : String(parseError),
|
||||
})
|
||||
throw new Error('Invalid JSON in request body')
|
||||
}
|
||||
|
||||
const validationResult = proxyPostSchema.safeParse(requestBody)
|
||||
if (!validationResult.success) {
|
||||
logger.error(`[${requestId}] Request validation failed`, {
|
||||
errors: validationResult.error.errors,
|
||||
})
|
||||
const errorMessages = validationResult.error.errors
|
||||
.map((err) => `${err.path.join('.')}: ${err.message}`)
|
||||
.join(', ')
|
||||
throw new Error(`Validation failed: ${errorMessages}`)
|
||||
}
|
||||
|
||||
const { toolId, params } = validationResult.data
|
||||
|
||||
logger.info(`[${requestId}] Processing tool: ${toolId}`)
|
||||
|
||||
const tool = getTool(toolId)
|
||||
|
||||
if (!tool) {
|
||||
logger.error(`[${requestId}] Tool not found: ${toolId}`)
|
||||
throw new Error(`Tool not found: ${toolId}`)
|
||||
}
|
||||
|
||||
try {
|
||||
validateRequiredParametersAfterMerge(toolId, tool, params)
|
||||
} catch (validationError) {
|
||||
logger.warn(`[${requestId}] Tool validation failed for ${toolId}`, {
|
||||
error: validationError instanceof Error ? validationError.message : String(validationError),
|
||||
})
|
||||
|
||||
const endTime = new Date()
|
||||
const endTimeISO = endTime.toISOString()
|
||||
const duration = endTime.getTime() - startTime.getTime()
|
||||
|
||||
return createErrorResponse(validationError, 400, {
|
||||
startTime: startTimeISO,
|
||||
endTime: endTimeISO,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
|
||||
const hasFileOutputs =
|
||||
tool.outputs &&
|
||||
Object.values(tool.outputs).some(
|
||||
(output) => output.type === 'file' || output.type === 'file[]'
|
||||
)
|
||||
|
||||
const result = await executeTool(
|
||||
toolId,
|
||||
params,
|
||||
true, // skipProxy (we're already in the proxy)
|
||||
!hasFileOutputs, // skipPostProcess (don't skip if tool has file outputs)
|
||||
undefined // execution context is not available in proxy context
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn(`[${requestId}] Tool execution failed for ${toolId}`, {
|
||||
error: result.error || 'Unknown error',
|
||||
})
|
||||
|
||||
throw new Error(result.error || 'Tool execution failed')
|
||||
}
|
||||
|
||||
const endTime = new Date()
|
||||
const endTimeISO = endTime.toISOString()
|
||||
const duration = endTime.getTime() - startTime.getTime()
|
||||
|
||||
const responseWithTimingData = {
|
||||
...result,
|
||||
startTime: startTimeISO,
|
||||
endTime: endTimeISO,
|
||||
duration,
|
||||
timing: {
|
||||
startTime: startTimeISO,
|
||||
endTime: endTimeISO,
|
||||
duration,
|
||||
},
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Tool executed successfully: ${toolId} (${duration}ms)`)
|
||||
|
||||
return formatResponse(responseWithTimingData)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Proxy request failed`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
name: error instanceof Error ? error.name : undefined,
|
||||
})
|
||||
|
||||
const endTime = new Date()
|
||||
const endTimeISO = endTime.toISOString()
|
||||
const duration = endTime.getTime() - startTime.getTime()
|
||||
|
||||
return createErrorResponse(error, 500, {
|
||||
startTime: startTimeISO,
|
||||
endTime: endTimeISO,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
})
|
||||
}
|
||||
138
apps/sim/app/api/table/[tableId]/route.ts
Normal file
138
apps/sim/app/api/table/[tableId]/route.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { deleteTable, type TableSchema } from '@/lib/table'
|
||||
import { accessError, checkAccess, normalizeColumn, verifyTableWorkspace } from '../utils'
|
||||
|
||||
const logger = createLogger('TableDetailAPI')
|
||||
|
||||
const GetTableSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
})
|
||||
|
||||
interface TableRouteParams {
|
||||
params: Promise<{ tableId: string }>
|
||||
}
|
||||
|
||||
/** GET /api/table/[tableId] - Retrieves a single table's details. */
|
||||
export async function GET(request: NextRequest, { params }: TableRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized table access attempt`)
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const validated = GetTableSchema.parse({
|
||||
workspaceId: searchParams.get('workspaceId'),
|
||||
})
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'read')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Retrieved table ${tableId} for user ${authResult.userId}`)
|
||||
|
||||
const schemaData = table.schema as TableSchema
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
table: {
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
description: table.description,
|
||||
schema: {
|
||||
columns: schemaData.columns.map(normalizeColumn),
|
||||
},
|
||||
rowCount: table.rowCount,
|
||||
maxRows: table.maxRows,
|
||||
createdAt:
|
||||
table.createdAt instanceof Date
|
||||
? table.createdAt.toISOString()
|
||||
: String(table.createdAt),
|
||||
updatedAt:
|
||||
table.updatedAt instanceof Date
|
||||
? table.updatedAt.toISOString()
|
||||
: String(table.updatedAt),
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error getting table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to get table' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/table/[tableId] - Deletes a table and all its rows. */
|
||||
export async function DELETE(request: NextRequest, { params }: TableRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized table delete attempt`)
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const validated = GetTableSchema.parse({
|
||||
workspaceId: searchParams.get('workspaceId'),
|
||||
})
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await deleteTable(tableId, requestId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Table deleted successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error deleting table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
276
apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts
Normal file
276
apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { db } from '@sim/db'
|
||||
import { userTableRows } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { RowData, TableSchema } from '@/lib/table'
|
||||
import { validateRowData } from '@/lib/table'
|
||||
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
|
||||
|
||||
const logger = createLogger('TableRowAPI')
|
||||
|
||||
const GetRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
})
|
||||
|
||||
const UpdateRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
|
||||
})
|
||||
|
||||
const DeleteRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
})
|
||||
|
||||
interface RowRouteParams {
|
||||
params: Promise<{ tableId: string; rowId: string }>
|
||||
}
|
||||
|
||||
/** GET /api/table/[tableId]/rows/[rowId] - Retrieves a single row. */
|
||||
export async function GET(request: NextRequest, { params }: RowRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId, rowId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const validated = GetRowSchema.parse({
|
||||
workspaceId: searchParams.get('workspaceId'),
|
||||
})
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'read')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: userTableRows.id,
|
||||
data: userTableRows.data,
|
||||
createdAt: userTableRows.createdAt,
|
||||
updatedAt: userTableRows.updatedAt,
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Retrieved row ${rowId} from table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: row.id,
|
||||
data: row.data,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error getting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to get row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** PATCH /api/table/[tableId]/rows/[rowId] - Updates a single row (supports partial updates). */
|
||||
export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId, rowId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
const validated = UpdateRowSchema.parse(body)
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch existing row to support partial updates
|
||||
const [existingRow] = await db
|
||||
.select({ data: userTableRows.data })
|
||||
.from(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingRow) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Merge existing data with incoming partial data (incoming takes precedence)
|
||||
const mergedData = {
|
||||
...(existingRow.data as RowData),
|
||||
...(validated.data as RowData),
|
||||
}
|
||||
|
||||
const validation = await validateRowData({
|
||||
rowData: mergedData,
|
||||
schema: table.schema as TableSchema,
|
||||
tableId,
|
||||
excludeRowId: rowId,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const [updatedRow] = await db
|
||||
.update(userTableRows)
|
||||
.set({
|
||||
data: mergedData,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!updatedRow) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Updated row ${rowId} in table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: updatedRow.id,
|
||||
data: updatedRow.data,
|
||||
createdAt: updatedRow.createdAt.toISOString(),
|
||||
updatedAt: updatedRow.updatedAt.toISOString(),
|
||||
},
|
||||
message: 'Row updated successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to update row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/table/[tableId]/rows/[rowId] - Deletes a single row. */
|
||||
export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId, rowId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
const validated = DeleteRowSchema.parse(body)
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [deletedRow] = await db
|
||||
.delete(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.id, rowId),
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!deletedRow) {
|
||||
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Deleted row ${rowId} from table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Row deleted successfully',
|
||||
deletedCount: 1,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error deleting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
681
apps/sim/app/api/table/[tableId]/rows/route.ts
Normal file
681
apps/sim/app/api/table/[tableId]/rows/route.ts
Normal file
@@ -0,0 +1,681 @@
|
||||
import { db } from '@sim/db'
|
||||
import { userTableRows } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
|
||||
import {
|
||||
checkUniqueConstraintsDb,
|
||||
getUniqueColumns,
|
||||
TABLE_LIMITS,
|
||||
USER_TABLE_ROWS_SQL_NAME,
|
||||
validateBatchRows,
|
||||
validateRowAgainstSchema,
|
||||
validateRowData,
|
||||
validateRowSize,
|
||||
} from '@/lib/table'
|
||||
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
|
||||
import { accessError, checkAccess } from '../../utils'
|
||||
|
||||
const logger = createLogger('TableRowsAPI')
|
||||
|
||||
const InsertRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
|
||||
})
|
||||
|
||||
const BatchInsertRowsSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
rows: z
|
||||
.array(z.record(z.unknown()), { required_error: 'Rows array is required' })
|
||||
.min(1, 'At least one row is required')
|
||||
.max(1000, 'Cannot insert more than 1000 rows per batch'),
|
||||
})
|
||||
|
||||
const QueryRowsSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
filter: z.record(z.unknown()).optional(),
|
||||
sort: z.record(z.enum(['asc', 'desc'])).optional(),
|
||||
limit: z.coerce
|
||||
.number({ required_error: 'Limit must be a number' })
|
||||
.int('Limit must be an integer')
|
||||
.min(1, 'Limit must be at least 1')
|
||||
.max(TABLE_LIMITS.MAX_QUERY_LIMIT, `Limit cannot exceed ${TABLE_LIMITS.MAX_QUERY_LIMIT}`)
|
||||
.optional()
|
||||
.default(100),
|
||||
offset: z.coerce
|
||||
.number({ required_error: 'Offset must be a number' })
|
||||
.int('Offset must be an integer')
|
||||
.min(0, 'Offset must be 0 or greater')
|
||||
.optional()
|
||||
.default(0),
|
||||
})
|
||||
|
||||
const UpdateRowsByFilterSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
|
||||
data: z.record(z.unknown(), { required_error: 'Update data is required' }),
|
||||
limit: z.coerce
|
||||
.number({ required_error: 'Limit must be a number' })
|
||||
.int('Limit must be an integer')
|
||||
.min(1, 'Limit must be at least 1')
|
||||
.max(1000, 'Cannot update more than 1000 rows per operation')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const DeleteRowsByFilterSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
|
||||
limit: z.coerce
|
||||
.number({ required_error: 'Limit must be a number' })
|
||||
.int('Limit must be an integer')
|
||||
.min(1, 'Limit must be at least 1')
|
||||
.max(1000, 'Cannot delete more than 1000 rows per operation')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
interface TableRowsRouteParams {
|
||||
params: Promise<{ tableId: string }>
|
||||
}
|
||||
|
||||
async function handleBatchInsert(
|
||||
requestId: string,
|
||||
tableId: string,
|
||||
body: z.infer<typeof BatchInsertRowsSchema>,
|
||||
userId: string
|
||||
): Promise<NextResponse> {
|
||||
const validated = BatchInsertRowsSchema.parse(body)
|
||||
|
||||
const accessResult = await checkAccess(tableId, userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const workspaceId = validated.workspaceId
|
||||
|
||||
const remainingCapacity = table.maxRows - table.rowCount
|
||||
if (remainingCapacity < validated.rows.length) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Insufficient capacity. Can only insert ${remainingCapacity} more rows (table has ${table.rowCount}/${table.maxRows} rows)`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const validation = await validateBatchRows({
|
||||
rows: validated.rows as RowData[],
|
||||
schema: table.schema as TableSchema,
|
||||
tableId,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
const now = new Date()
|
||||
const rowsToInsert = validated.rows.map((data) => ({
|
||||
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
|
||||
tableId,
|
||||
workspaceId,
|
||||
data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: userId,
|
||||
}))
|
||||
|
||||
const insertedRows = await db.insert(userTableRows).values(rowsToInsert).returning()
|
||||
|
||||
logger.info(`[${requestId}] Batch inserted ${insertedRows.length} rows into table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: insertedRows.map((r) => ({
|
||||
id: r.id,
|
||||
data: r.data,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})),
|
||||
insertedCount: insertedRows.length,
|
||||
message: `Successfully inserted ${insertedRows.length} rows`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /api/table/[tableId]/rows - Inserts row(s). Supports single or batch insert. */
|
||||
export async function POST(request: NextRequest, { params }: TableRowsRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
'rows' in body &&
|
||||
Array.isArray((body as Record<string, unknown>).rows)
|
||||
) {
|
||||
return handleBatchInsert(
|
||||
requestId,
|
||||
tableId,
|
||||
body as z.infer<typeof BatchInsertRowsSchema>,
|
||||
authResult.userId
|
||||
)
|
||||
}
|
||||
|
||||
const validated = InsertRowSchema.parse(body)
|
||||
|
||||
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const workspaceId = validated.workspaceId
|
||||
const rowData = validated.data as RowData
|
||||
|
||||
const validation = await validateRowData({
|
||||
rowData,
|
||||
schema: table.schema as TableSchema,
|
||||
tableId,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
if (table.rowCount >= table.maxRows) {
|
||||
return NextResponse.json(
|
||||
{ error: `Table row limit reached (${table.maxRows} rows max)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const rowId = `row_${crypto.randomUUID().replace(/-/g, '')}`
|
||||
const now = new Date()
|
||||
|
||||
const [row] = await db
|
||||
.insert(userTableRows)
|
||||
.values({
|
||||
id: rowId,
|
||||
tableId,
|
||||
workspaceId,
|
||||
data: validated.data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: authResult.userId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Inserted row ${rowId} into table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: row.id,
|
||||
data: row.data,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
message: 'Row inserted successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error inserting row:`, error)
|
||||
return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/table/[tableId]/rows - Queries rows with filtering, sorting, and pagination. */
|
||||
export async function GET(request: NextRequest, { params }: TableRowsRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
const filterParam = searchParams.get('filter')
|
||||
const sortParam = searchParams.get('sort')
|
||||
const limit = searchParams.get('limit')
|
||||
const offset = searchParams.get('offset')
|
||||
|
||||
let filter: Record<string, unknown> | undefined
|
||||
let sort: Sort | undefined
|
||||
|
||||
try {
|
||||
if (filterParam) {
|
||||
filter = JSON.parse(filterParam) as Record<string, unknown>
|
||||
}
|
||||
if (sortParam) {
|
||||
sort = JSON.parse(sortParam) as Sort
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validated = QueryRowsSchema.parse({
|
||||
workspaceId,
|
||||
filter,
|
||||
sort,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
|
||||
const accessResult = await checkAccess(tableId, authResult.userId, 'read')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const baseConditions = [
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
]
|
||||
|
||||
if (validated.filter) {
|
||||
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
|
||||
if (filterClause) {
|
||||
baseConditions.push(filterClause)
|
||||
}
|
||||
}
|
||||
|
||||
let query = db
|
||||
.select({
|
||||
id: userTableRows.id,
|
||||
data: userTableRows.data,
|
||||
createdAt: userTableRows.createdAt,
|
||||
updatedAt: userTableRows.updatedAt,
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
|
||||
if (validated.sort) {
|
||||
const schema = table.schema as TableSchema
|
||||
const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns)
|
||||
if (sortClause) {
|
||||
query = query.orderBy(sortClause) as typeof query
|
||||
}
|
||||
} else {
|
||||
query = query.orderBy(userTableRows.createdAt) as typeof query
|
||||
}
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
|
||||
const [{ count: totalCount }] = await countQuery
|
||||
|
||||
const rows = await query.limit(validated.limit).offset(validated.offset)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount})`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: rows.map((r) => ({
|
||||
id: r.id,
|
||||
data: r.data,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})),
|
||||
rowCount: rows.length,
|
||||
totalCount: Number(totalCount),
|
||||
limit: validated.limit,
|
||||
offset: validated.offset,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error querying rows:`, error)
|
||||
return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** PUT /api/table/[tableId]/rows - Updates rows matching filter criteria. */
|
||||
export async function PUT(request: NextRequest, { params }: TableRowsRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
const validated = UpdateRowsByFilterSchema.parse(body)
|
||||
|
||||
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updateData = validated.data as RowData
|
||||
|
||||
const sizeValidation = validateRowSize(updateData)
|
||||
if (!sizeValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid row data', details: sizeValidation.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const baseConditions = [
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
]
|
||||
|
||||
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
|
||||
if (filterClause) {
|
||||
baseConditions.push(filterClause)
|
||||
}
|
||||
|
||||
let matchingRowsQuery = db
|
||||
.select({
|
||||
id: userTableRows.id,
|
||||
data: userTableRows.data,
|
||||
})
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
|
||||
if (validated.limit) {
|
||||
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
|
||||
}
|
||||
|
||||
const matchingRows = await matchingRowsQuery
|
||||
|
||||
if (matchingRows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
message: 'No rows matched the filter criteria',
|
||||
updatedCount: 0,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
if (matchingRows.length > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) {
|
||||
logger.warn(`[${requestId}] Updating ${matchingRows.length} rows. This may take some time.`)
|
||||
}
|
||||
|
||||
for (const row of matchingRows) {
|
||||
const existingData = row.data as RowData
|
||||
const mergedData = { ...existingData, ...updateData }
|
||||
const rowValidation = validateRowAgainstSchema(mergedData, table.schema as TableSchema)
|
||||
if (!rowValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Updated data does not match schema',
|
||||
details: rowValidation.errors,
|
||||
affectedRowId: row.id,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueColumns = getUniqueColumns(table.schema as TableSchema)
|
||||
if (uniqueColumns.length > 0) {
|
||||
// If updating multiple rows, check that updateData doesn't set any unique column
|
||||
// (would cause all rows to have the same value, violating uniqueness)
|
||||
if (matchingRows.length > 1) {
|
||||
const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in updateData)
|
||||
if (uniqueColumnsInUpdate.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Cannot set unique column values when updating multiple rows',
|
||||
details: [
|
||||
`Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` +
|
||||
`Updating ${matchingRows.length} rows with the same value would violate uniqueness.`,
|
||||
],
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check unique constraints against database for each row
|
||||
for (const row of matchingRows) {
|
||||
const existingData = row.data as RowData
|
||||
const mergedData = { ...existingData, ...updateData }
|
||||
const uniqueValidation = await checkUniqueConstraintsDb(
|
||||
tableId,
|
||||
mergedData,
|
||||
table.schema as TableSchema,
|
||||
row.id
|
||||
)
|
||||
|
||||
if (!uniqueValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unique constraint violation',
|
||||
details: uniqueValidation.errors,
|
||||
affectedRowId: row.id,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
let totalUpdated = 0
|
||||
|
||||
for (let i = 0; i < matchingRows.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
|
||||
const batch = matchingRows.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
|
||||
const updatePromises = batch.map((row) => {
|
||||
const existingData = row.data as RowData
|
||||
return trx
|
||||
.update(userTableRows)
|
||||
.set({
|
||||
data: { ...existingData, ...updateData },
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(userTableRows.id, row.id))
|
||||
})
|
||||
await Promise.all(updatePromises)
|
||||
totalUpdated += batch.length
|
||||
logger.info(
|
||||
`[${requestId}] Updated batch ${Math.floor(i / TABLE_LIMITS.UPDATE_BATCH_SIZE) + 1} (${totalUpdated}/${matchingRows.length} rows)`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Rows updated successfully',
|
||||
updatedCount: matchingRows.length,
|
||||
updatedRowIds: matchingRows.map((r) => r.id),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating rows by filter:`, error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const detailedError = `Failed to update rows: ${errorMessage}`
|
||||
|
||||
return NextResponse.json({ error: detailedError }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/table/[tableId]/rows - Deletes rows matching filter criteria. */
|
||||
export async function DELETE(request: NextRequest, { params }: TableRowsRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
const validated = DeleteRowsByFilterSchema.parse(body)
|
||||
|
||||
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
|
||||
|
||||
const { table } = accessResult
|
||||
|
||||
if (validated.workspaceId !== table.workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const baseConditions = [
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
]
|
||||
|
||||
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
|
||||
if (filterClause) {
|
||||
baseConditions.push(filterClause)
|
||||
}
|
||||
|
||||
let matchingRowsQuery = db
|
||||
.select({ id: userTableRows.id })
|
||||
.from(userTableRows)
|
||||
.where(and(...baseConditions))
|
||||
|
||||
if (validated.limit) {
|
||||
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
|
||||
}
|
||||
|
||||
const matchingRows = await matchingRowsQuery
|
||||
|
||||
if (matchingRows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
message: 'No rows matched the filter criteria',
|
||||
deletedCount: 0,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
if (matchingRows.length > TABLE_LIMITS.DELETE_BATCH_SIZE) {
|
||||
logger.warn(`[${requestId}] Deleting ${matchingRows.length} rows. This may take some time.`)
|
||||
}
|
||||
|
||||
const rowIds = matchingRows.map((r) => r.id)
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
let totalDeleted = 0
|
||||
|
||||
for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) {
|
||||
const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE)
|
||||
await trx.delete(userTableRows).where(
|
||||
and(
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
sql`${userTableRows.id} = ANY(ARRAY[${sql.join(
|
||||
batch.map((id) => sql`${id}`),
|
||||
sql`, `
|
||||
)}])`
|
||||
)
|
||||
)
|
||||
totalDeleted += batch.length
|
||||
logger.info(
|
||||
`[${requestId}] Deleted batch ${Math.floor(i / TABLE_LIMITS.DELETE_BATCH_SIZE) + 1} (${totalDeleted}/${rowIds.length} rows)`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${tableId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Rows deleted successfully',
|
||||
deletedCount: matchingRows.length,
|
||||
deletedRowIds: rowIds,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error deleting rows by filter:`, error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const detailedError = `Failed to delete rows: ${errorMessage}`
|
||||
|
||||
return NextResponse.json({ error: detailedError }, { status: 500 })
|
||||
}
|
||||
}
|
||||
182
apps/sim/app/api/table/[tableId]/rows/upsert/route.ts
Normal file
182
apps/sim/app/api/table/[tableId]/rows/upsert/route.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { db } from '@sim/db'
|
||||
import { userTableRows } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, or, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { RowData, TableSchema } from '@/lib/table'
|
||||
import { getUniqueColumns, validateRowData } from '@/lib/table'
|
||||
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
|
||||
|
||||
const logger = createLogger('TableUpsertAPI')
|
||||
|
||||
const UpsertRowSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
|
||||
})
|
||||
|
||||
interface UpsertRouteParams {
|
||||
params: Promise<{ tableId: string }>
|
||||
}
|
||||
|
||||
/** POST /api/table/[tableId]/rows/upsert - Inserts or updates based on unique columns. */
|
||||
export async function POST(request: NextRequest, { params }: UpsertRouteParams) {
|
||||
const requestId = generateRequestId()
|
||||
const { tableId } = await params
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
const validated = UpsertRowSchema.parse(body)
|
||||
|
||||
const result = await checkAccess(tableId, authResult.userId, 'write')
|
||||
if (!result.ok) return accessError(result, requestId, tableId)
|
||||
|
||||
const { table } = result
|
||||
|
||||
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
|
||||
if (!isValidWorkspace) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const schema = table.schema as TableSchema
|
||||
const rowData = validated.data as RowData
|
||||
|
||||
const validation = await validateRowData({
|
||||
rowData,
|
||||
schema,
|
||||
tableId,
|
||||
checkUnique: false,
|
||||
})
|
||||
if (!validation.valid) return validation.response
|
||||
|
||||
const uniqueColumns = getUniqueColumns(schema)
|
||||
|
||||
if (uniqueColumns.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Upsert requires at least one unique column in the schema. Please add a unique constraint to a column or use insert instead.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const uniqueFilters = uniqueColumns.map((col) => {
|
||||
const value = rowData[col.name]
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
return sql`${userTableRows.data}->>${col.name} = ${String(value)}`
|
||||
})
|
||||
|
||||
const validUniqueFilters = uniqueFilters.filter((f): f is Exclude<typeof f, null> => f !== null)
|
||||
|
||||
if (validUniqueFilters.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Upsert requires values for at least one unique field: ${uniqueColumns.map((c) => c.name).join(', ')}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const [existingRow] = await db
|
||||
.select()
|
||||
.from(userTableRows)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableRows.tableId, tableId),
|
||||
eq(userTableRows.workspaceId, validated.workspaceId),
|
||||
or(...validUniqueFilters)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const now = new Date()
|
||||
|
||||
if (!existingRow && table.rowCount >= table.maxRows) {
|
||||
return NextResponse.json(
|
||||
{ error: `Table row limit reached (${table.maxRows} rows max)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const upsertResult = await db.transaction(async (trx) => {
|
||||
if (existingRow) {
|
||||
const [updatedRow] = await trx
|
||||
.update(userTableRows)
|
||||
.set({
|
||||
data: validated.data,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(userTableRows.id, existingRow.id))
|
||||
.returning()
|
||||
|
||||
return {
|
||||
row: updatedRow,
|
||||
operation: 'update' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const [insertedRow] = await trx
|
||||
.insert(userTableRows)
|
||||
.values({
|
||||
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
|
||||
tableId,
|
||||
workspaceId: validated.workspaceId,
|
||||
data: validated.data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: authResult.userId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return {
|
||||
row: insertedRow,
|
||||
operation: 'insert' as const,
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Upserted (${upsertResult.operation}) row ${upsertResult.row.id} in table ${tableId}`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
row: {
|
||||
id: upsertResult.row.id,
|
||||
data: upsertResult.row.data,
|
||||
createdAt: upsertResult.row.createdAt.toISOString(),
|
||||
updatedAt: upsertResult.row.updatedAt.toISOString(),
|
||||
},
|
||||
operation: upsertResult.operation,
|
||||
message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error upserting row:`, error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const detailedError = `Failed to upsert row: ${errorMessage}`
|
||||
|
||||
return NextResponse.json({ error: detailedError }, { status: 500 })
|
||||
}
|
||||
}
|
||||
293
apps/sim/app/api/table/route.ts
Normal file
293
apps/sim/app/api/table/route.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import {
|
||||
canCreateTable,
|
||||
createTable,
|
||||
getWorkspaceTableLimits,
|
||||
listTables,
|
||||
TABLE_LIMITS,
|
||||
type TableSchema,
|
||||
} from '@/lib/table'
|
||||
import { normalizeColumn } from './utils'
|
||||
|
||||
const logger = createLogger('TableAPI')
|
||||
|
||||
const ColumnSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Column name is required')
|
||||
.max(
|
||||
TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH,
|
||||
`Column name must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less`
|
||||
)
|
||||
.regex(
|
||||
/^[a-z_][a-z0-9_]*$/i,
|
||||
'Column name must start with a letter or underscore and contain only alphanumeric characters and underscores'
|
||||
),
|
||||
type: z.enum(['string', 'number', 'boolean', 'date', 'json'], {
|
||||
errorMap: () => ({
|
||||
message: 'Column type must be one of: string, number, boolean, date, json',
|
||||
}),
|
||||
}),
|
||||
required: z.boolean().optional().default(false),
|
||||
unique: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
const CreateTableSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Table name is required')
|
||||
.max(
|
||||
TABLE_LIMITS.MAX_TABLE_NAME_LENGTH,
|
||||
`Table name must be ${TABLE_LIMITS.MAX_TABLE_NAME_LENGTH} characters or less`
|
||||
)
|
||||
.regex(
|
||||
/^[a-z_][a-z0-9_]*$/i,
|
||||
'Table name must start with a letter or underscore and contain only alphanumeric characters and underscores'
|
||||
),
|
||||
description: z
|
||||
.string()
|
||||
.max(
|
||||
TABLE_LIMITS.MAX_DESCRIPTION_LENGTH,
|
||||
`Description must be ${TABLE_LIMITS.MAX_DESCRIPTION_LENGTH} characters or less`
|
||||
)
|
||||
.optional(),
|
||||
schema: z.object({
|
||||
columns: z
|
||||
.array(ColumnSchema)
|
||||
.min(1, 'Table must have at least one column')
|
||||
.max(
|
||||
TABLE_LIMITS.MAX_COLUMNS_PER_TABLE,
|
||||
`Table cannot have more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns`
|
||||
),
|
||||
}),
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
})
|
||||
|
||||
const ListTablesSchema = z.object({
|
||||
workspaceId: z.string().min(1, 'Workspace ID is required'),
|
||||
})
|
||||
|
||||
interface WorkspaceAccessResult {
|
||||
hasAccess: boolean
|
||||
canWrite: boolean
|
||||
}
|
||||
|
||||
async function checkWorkspaceAccess(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspaceAccessResult> {
|
||||
const [workspaceData] = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
ownerId: workspace.ownerId,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceData) {
|
||||
return { hasAccess: false, canWrite: false }
|
||||
}
|
||||
|
||||
if (workspaceData.ownerId === userId) {
|
||||
return { hasAccess: true, canWrite: true }
|
||||
}
|
||||
|
||||
const [permission] = await db
|
||||
.select({
|
||||
permissionType: permissions.permissionType,
|
||||
})
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!permission) {
|
||||
return { hasAccess: false, canWrite: false }
|
||||
}
|
||||
|
||||
const canWrite = permission.permissionType === 'admin' || permission.permissionType === 'write'
|
||||
|
||||
return {
|
||||
hasAccess: true,
|
||||
canWrite,
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table - Creates a new user-defined table. */
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: unknown = await request.json()
|
||||
const params = CreateTableSchema.parse(body)
|
||||
|
||||
const { hasAccess, canWrite } = await checkWorkspaceAccess(
|
||||
params.workspaceId,
|
||||
authResult.userId
|
||||
)
|
||||
|
||||
if (!hasAccess || !canWrite) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check billing plan limits
|
||||
const existingTables = await listTables(params.workspaceId)
|
||||
const { canCreate, maxTables } = await canCreateTable(params.workspaceId, existingTables.length)
|
||||
|
||||
if (!canCreate) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Workspace has reached the maximum table limit (${maxTables}) for your plan. Please upgrade to create more tables.`,
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get plan-based row limits
|
||||
const planLimits = await getWorkspaceTableLimits(params.workspaceId)
|
||||
const maxRowsPerTable = planLimits.maxRowsPerTable
|
||||
|
||||
const normalizedSchema: TableSchema = {
|
||||
columns: params.schema.columns.map(normalizeColumn),
|
||||
}
|
||||
|
||||
const table = await createTable(
|
||||
{
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
schema: normalizedSchema,
|
||||
workspaceId: params.workspaceId,
|
||||
userId: authResult.userId,
|
||||
maxRows: maxRowsPerTable,
|
||||
},
|
||||
requestId
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
table: {
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
description: table.description,
|
||||
schema: table.schema,
|
||||
rowCount: table.rowCount,
|
||||
maxRows: table.maxRows,
|
||||
createdAt:
|
||||
table.createdAt instanceof Date
|
||||
? table.createdAt.toISOString()
|
||||
: String(table.createdAt),
|
||||
updatedAt:
|
||||
table.updatedAt instanceof Date
|
||||
? table.updatedAt.toISOString()
|
||||
: String(table.updatedAt),
|
||||
},
|
||||
message: 'Table created successfully',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message.includes('Invalid table name') ||
|
||||
error.message.includes('Invalid schema') ||
|
||||
error.message.includes('already exists') ||
|
||||
error.message.includes('maximum table limit')
|
||||
) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error creating table:`, error)
|
||||
return NextResponse.json({ error: 'Failed to create table' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/table - Lists all tables in a workspace. */
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
|
||||
const validation = ListTablesSchema.safeParse({ workspaceId })
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: validation.error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const params = validation.data
|
||||
|
||||
const { hasAccess } = await checkWorkspaceAccess(params.workspaceId, authResult.userId)
|
||||
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const tables = await listTables(params.workspaceId)
|
||||
|
||||
logger.info(`[${requestId}] Listed ${tables.length} tables in workspace ${params.workspaceId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tables: tables.map((t) => {
|
||||
const schemaData = t.schema as TableSchema
|
||||
return {
|
||||
...t,
|
||||
schema: {
|
||||
columns: schemaData.columns.map(normalizeColumn),
|
||||
},
|
||||
createdAt:
|
||||
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
|
||||
updatedAt:
|
||||
t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),
|
||||
}
|
||||
}),
|
||||
totalCount: tables.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error listing tables:`, error)
|
||||
return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
188
apps/sim/app/api/table/utils.ts
Normal file
188
apps/sim/app/api/table/utils.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { ColumnDefinition, TableDefinition } from '@/lib/table'
|
||||
import { getTableById } from '@/lib/table'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('TableUtils')
|
||||
|
||||
export interface TableAccessResult {
|
||||
hasAccess: true
|
||||
table: TableDefinition
|
||||
}
|
||||
|
||||
export interface TableAccessDenied {
|
||||
hasAccess: false
|
||||
notFound?: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type TableAccessCheck = TableAccessResult | TableAccessDenied
|
||||
|
||||
export type AccessResult = { ok: true; table: TableDefinition } | { ok: false; status: 404 | 403 }
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
error: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has read access to a table.
|
||||
* Read access is granted if:
|
||||
* 1. User created the table, OR
|
||||
* 2. User has any permission on the table's workspace (read, write, or admin)
|
||||
*
|
||||
* Follows the same pattern as Knowledge Base access checks.
|
||||
*/
|
||||
export async function checkTableAccess(tableId: string, userId: string): Promise<TableAccessCheck> {
|
||||
const table = await getTableById(tableId)
|
||||
|
||||
if (!table) {
|
||||
return { hasAccess: false, notFound: true }
|
||||
}
|
||||
|
||||
// Case 1: User created the table
|
||||
if (table.createdBy === userId) {
|
||||
return { hasAccess: true, table }
|
||||
}
|
||||
|
||||
// Case 2: Table belongs to a workspace the user has permissions for
|
||||
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
|
||||
if (userPermission !== null) {
|
||||
return { hasAccess: true, table }
|
||||
}
|
||||
|
||||
return { hasAccess: false, reason: 'User does not have access to this table' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has write access to a table.
|
||||
* Write access is granted if:
|
||||
* 1. User created the table, OR
|
||||
* 2. User has write or admin permissions on the table's workspace
|
||||
*
|
||||
* Follows the same pattern as Knowledge Base write access checks.
|
||||
*/
|
||||
export async function checkTableWriteAccess(
|
||||
tableId: string,
|
||||
userId: string
|
||||
): Promise<TableAccessCheck> {
|
||||
const table = await getTableById(tableId)
|
||||
|
||||
if (!table) {
|
||||
return { hasAccess: false, notFound: true }
|
||||
}
|
||||
|
||||
// Case 1: User created the table
|
||||
if (table.createdBy === userId) {
|
||||
return { hasAccess: true, table }
|
||||
}
|
||||
|
||||
// Case 2: Table belongs to a workspace and user has write/admin permissions
|
||||
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
|
||||
if (userPermission === 'write' || userPermission === 'admin') {
|
||||
return { hasAccess: true, table }
|
||||
}
|
||||
|
||||
return { hasAccess: false, reason: 'User does not have write access to this table' }
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use checkTableAccess or checkTableWriteAccess instead.
|
||||
* Legacy access check function for backwards compatibility.
|
||||
*/
|
||||
export async function checkAccess(
|
||||
tableId: string,
|
||||
userId: string,
|
||||
level: 'read' | 'write' | 'admin' = 'read'
|
||||
): Promise<AccessResult> {
|
||||
const table = await getTableById(tableId)
|
||||
|
||||
if (!table) {
|
||||
return { ok: false, status: 404 }
|
||||
}
|
||||
|
||||
if (table.createdBy === userId) {
|
||||
return { ok: true, table }
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
|
||||
const hasAccess =
|
||||
permission !== null &&
|
||||
(level === 'read' ||
|
||||
(level === 'write' && (permission === 'write' || permission === 'admin')) ||
|
||||
(level === 'admin' && permission === 'admin'))
|
||||
|
||||
return hasAccess ? { ok: true, table } : { ok: false, status: 403 }
|
||||
}
|
||||
|
||||
export function accessError(
|
||||
result: { ok: false; status: 404 | 403 },
|
||||
requestId: string,
|
||||
context?: string
|
||||
): NextResponse {
|
||||
const message = result.status === 404 ? 'Table not found' : 'Access denied'
|
||||
logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
|
||||
return NextResponse.json({ error: message }, { status: result.status })
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a TableAccessDenied result to an appropriate HTTP response.
|
||||
* Use with checkTableAccess or checkTableWriteAccess.
|
||||
*/
|
||||
export function tableAccessError(
|
||||
result: TableAccessDenied,
|
||||
requestId: string,
|
||||
context?: string
|
||||
): NextResponse {
|
||||
const status = result.notFound ? 404 : 403
|
||||
const message = result.notFound ? 'Table not found' : (result.reason ?? 'Access denied')
|
||||
logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
|
||||
return NextResponse.json({ error: message }, { status })
|
||||
}
|
||||
|
||||
export async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise<boolean> {
|
||||
const table = await getTableById(tableId)
|
||||
return table?.workspaceId === workspaceId
|
||||
}
|
||||
|
||||
export function errorResponse(
|
||||
message: string,
|
||||
status: number,
|
||||
details?: unknown
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
const body: ApiErrorResponse = { error: message }
|
||||
if (details !== undefined) {
|
||||
body.details = details
|
||||
}
|
||||
return NextResponse.json(body, { status })
|
||||
}
|
||||
|
||||
export function badRequestResponse(message: string, details?: unknown) {
|
||||
return errorResponse(message, 400, details)
|
||||
}
|
||||
|
||||
export function unauthorizedResponse(message = 'Authentication required') {
|
||||
return errorResponse(message, 401)
|
||||
}
|
||||
|
||||
export function forbiddenResponse(message = 'Access denied') {
|
||||
return errorResponse(message, 403)
|
||||
}
|
||||
|
||||
export function notFoundResponse(message = 'Resource not found') {
|
||||
return errorResponse(message, 404)
|
||||
}
|
||||
|
||||
export function serverErrorResponse(message = 'Internal server error') {
|
||||
return errorResponse(message, 500)
|
||||
}
|
||||
|
||||
export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
|
||||
return {
|
||||
name: col.name,
|
||||
type: col.type,
|
||||
required: col.required ?? false,
|
||||
unique: col.unique ?? false,
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
extractStorageKey,
|
||||
inferContextFromKey,
|
||||
isInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -47,13 +51,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Mistral parse request`, {
|
||||
filePath: validatedData.filePath,
|
||||
isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'),
|
||||
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
|
||||
userId,
|
||||
})
|
||||
|
||||
let fileUrl = validatedData.filePath
|
||||
|
||||
if (validatedData.filePath?.includes('/api/files/serve/')) {
|
||||
if (isInternalFileUrl(validatedData.filePath)) {
|
||||
try {
|
||||
const storageKey = extractStorageKey(validatedData.filePath)
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
extractStorageKey,
|
||||
inferContextFromKey,
|
||||
isInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -48,13 +52,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Pulse parse request`, {
|
||||
filePath: validatedData.filePath,
|
||||
isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'),
|
||||
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
|
||||
userId,
|
||||
})
|
||||
|
||||
let fileUrl = validatedData.filePath
|
||||
|
||||
if (validatedData.filePath?.includes('/api/files/serve/')) {
|
||||
if (isInternalFileUrl(validatedData.filePath)) {
|
||||
try {
|
||||
const storageKey = extractStorageKey(validatedData.filePath)
|
||||
const context = inferContextFromKey(storageKey)
|
||||
|
||||
@@ -5,7 +5,11 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
extractStorageKey,
|
||||
inferContextFromKey,
|
||||
isInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -44,13 +48,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Reducto parse request`, {
|
||||
filePath: validatedData.filePath,
|
||||
isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'),
|
||||
isWorkspaceFile: isInternalFileUrl(validatedData.filePath),
|
||||
userId,
|
||||
})
|
||||
|
||||
let fileUrl = validatedData.filePath
|
||||
|
||||
if (validatedData.filePath?.includes('/api/files/serve/')) {
|
||||
if (isInternalFileUrl(validatedData.filePath)) {
|
||||
try {
|
||||
const storageKey = extractStorageKey(validatedData.filePath)
|
||||
const context = inferContextFromKey(storageKey)
|
||||
|
||||
@@ -79,11 +79,13 @@ export async function POST(request: NextRequest) {
|
||||
// Generate public URL for destination (properly encode the destination key)
|
||||
const encodedDestKey = validatedData.destinationKey.split('/').map(encodeURIComponent).join('/')
|
||||
const url = `https://${validatedData.destinationBucket}.s3.${validatedData.region}.amazonaws.com/${encodedDestKey}`
|
||||
const uri = `s3://${validatedData.destinationBucket}/${validatedData.destinationKey}`
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
url,
|
||||
uri,
|
||||
copySourceVersionId: result.CopySourceVersionId,
|
||||
versionId: result.VersionId,
|
||||
etag: result.CopyObjectResult?.ETag,
|
||||
|
||||
@@ -117,11 +117,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const encodedKey = validatedData.objectKey.split('/').map(encodeURIComponent).join('/')
|
||||
const url = `https://${validatedData.bucketName}.s3.${validatedData.region}.amazonaws.com/${encodedKey}`
|
||||
const uri = `s3://${validatedData.bucketName}/${validatedData.objectKey}`
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
url,
|
||||
uri,
|
||||
etag: result.ETag,
|
||||
location: url,
|
||||
key: validatedData.objectKey,
|
||||
|
||||
637
apps/sim/app/api/tools/textract/parse/route.ts
Normal file
637
apps/sim/app/api/tools/textract/parse/route.ts
Normal file
@@ -0,0 +1,637 @@
|
||||
import crypto from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
validateAwsRegion,
|
||||
validateExternalUrl,
|
||||
validateS3BucketName,
|
||||
} from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import {
|
||||
extractStorageKey,
|
||||
inferContextFromKey,
|
||||
isInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 300 // 5 minutes for large multi-page PDF processing
|
||||
|
||||
const logger = createLogger('TextractParseAPI')
|
||||
|
||||
const QuerySchema = z.object({
|
||||
Text: z.string().min(1),
|
||||
Alias: z.string().optional(),
|
||||
Pages: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
const TextractParseSchema = z
|
||||
.object({
|
||||
accessKeyId: z.string().min(1, 'AWS Access Key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS Secret Access Key is required'),
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
processingMode: z.enum(['sync', 'async']).optional().default('sync'),
|
||||
filePath: z.string().optional(),
|
||||
s3Uri: z.string().optional(),
|
||||
featureTypes: z
|
||||
.array(z.enum(['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES', 'LAYOUT']))
|
||||
.optional(),
|
||||
queries: z.array(QuerySchema).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const regionValidation = validateAwsRegion(data.region, 'AWS region')
|
||||
if (!regionValidation.isValid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: regionValidation.error,
|
||||
path: ['region'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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<string, string> {
|
||||
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 response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
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 }
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function callTextractAsync(
|
||||
host: string,
|
||||
amzTarget: string,
|
||||
body: Record<string, unknown>,
|
||||
accessKeyId: string,
|
||||
secretAccessKey: string,
|
||||
region: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
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<Record<string, unknown>> {
|
||||
const pollIntervalMs = 5000 // 5 seconds between polls
|
||||
const maxPollTimeMs = 180000 // 3 minutes maximum polling time
|
||||
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)`
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(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',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = authResult.userId
|
||||
const body = await request.json()
|
||||
const validatedData = TextractParseSchema.parse(body)
|
||||
|
||||
const processingMode = validatedData.processingMode || 'sync'
|
||||
const featureTypes = validatedData.featureTypes ?? []
|
||||
const useAnalyzeDocument = featureTypes.length > 0
|
||||
const host = `textract.${validatedData.region}.amazonaws.com`
|
||||
|
||||
logger.info(`[${requestId}] Textract parse request`, {
|
||||
processingMode,
|
||||
filePath: validatedData.filePath?.substring(0, 50),
|
||||
s3Uri: validatedData.s3Uri?.substring(0, 50),
|
||||
featureTypes,
|
||||
userId,
|
||||
})
|
||||
|
||||
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: 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<string, unknown> = {
|
||||
DocumentLocation: {
|
||||
S3Object: {
|
||||
Bucket: s3Bucket,
|
||||
Name: s3Key,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if (useAnalyzeDocument) {
|
||||
startBody.FeatureTypes = featureTypes
|
||||
|
||||
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
|
||||
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
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Textract async parse successful`, {
|
||||
pageCount: (textractData.DocumentMetadata as { Pages?: number })?.Pages ?? 0,
|
||||
blockCount: (textractData.Blocks as unknown[])?.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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!validatedData.filePath) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'File path is required for single-page processing',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let fileUrl = validatedData.filePath
|
||||
|
||||
const isInternalFilePath = validatedData.filePath && isInternalFileUrl(validatedData.filePath)
|
||||
|
||||
if (isInternalFilePath) {
|
||||
try {
|
||||
const storageKey = extractStorageKey(validatedData.filePath)
|
||||
const context = inferContextFromKey(storageKey)
|
||||
|
||||
const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false)
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, {
|
||||
userId,
|
||||
key: storageKey,
|
||||
context,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'File not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
|
||||
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to generate presigned URL:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to generate file access URL',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} else if (validatedData.filePath?.startsWith('/')) {
|
||||
// Reject arbitrary absolute paths that don't contain /api/files/serve/
|
||||
logger.warn(`[${requestId}] Invalid internal path`, {
|
||||
userId,
|
||||
path: validatedData.filePath.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 = validateExternalUrl(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 { bytes, contentType } = await fetchDocumentBytes(fileUrl)
|
||||
|
||||
// Track if this is a PDF for better error messaging
|
||||
const isPdf = contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf')
|
||||
|
||||
const uri = '/'
|
||||
|
||||
let textractBody: Record<string, unknown>
|
||||
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 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 }
|
||||
)
|
||||
}
|
||||
|
||||
const textractData = await textractResponse.json()
|
||||
|
||||
logger.info(`[${requestId}] Textract parse successful`, {
|
||||
pageCount: textractData.DocumentMetadata?.Pages ?? 0,
|
||||
blockCount: textractData.Blocks?.length ?? 0,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
blocks: textractData.Blocks ?? [],
|
||||
documentMetadata: {
|
||||
pages: textractData.DocumentMetadata?.Pages ?? 0,
|
||||
},
|
||||
modelVersion:
|
||||
textractData.AnalyzeDocumentModelVersion ??
|
||||
textractData.DetectDocumentTextModelVersion ??
|
||||
undefined,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error in Textract parse:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { enrichTableSchema } from '@/lib/table/llm/wand'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { getModelPricing } from '@/providers/utils'
|
||||
|
||||
@@ -60,6 +61,7 @@ interface RequestBody {
|
||||
history?: ChatMessage[]
|
||||
workflowId?: string
|
||||
generationType?: string
|
||||
wandContext?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
@@ -70,6 +72,38 @@ function safeStringify(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wand enricher function type.
|
||||
* Enrichers add context to the system prompt based on generationType.
|
||||
*/
|
||||
type WandEnricher = (
|
||||
workspaceId: string | null,
|
||||
context: Record<string, unknown>
|
||||
) => Promise<string | null>
|
||||
|
||||
/**
|
||||
* Registry of wand enrichers by generationType.
|
||||
* Each enricher returns additional context to append to the system prompt.
|
||||
*/
|
||||
const wandEnrichers: Partial<Record<string, WandEnricher>> = {
|
||||
timestamp: async () => {
|
||||
const now = new Date()
|
||||
return `Current date and time context for reference:
|
||||
- Current UTC timestamp: ${now.toISOString()}
|
||||
- Current Unix timestamp (seconds): ${Math.floor(now.getTime() / 1000)}
|
||||
- Current Unix timestamp (milliseconds): ${now.getTime()}
|
||||
- Current date (UTC): ${now.toISOString().split('T')[0]}
|
||||
- Current year: ${now.getUTCFullYear()}
|
||||
- Current month: ${now.getUTCMonth() + 1}
|
||||
- Current day of month: ${now.getUTCDate()}
|
||||
- Current day of week: ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][now.getUTCDay()]}
|
||||
|
||||
Use this context to calculate relative dates like "yesterday", "last week", "beginning of this month", etc.`
|
||||
},
|
||||
|
||||
'table-schema': enrichTableSchema,
|
||||
}
|
||||
|
||||
async function updateUserStatsForWand(
|
||||
userId: string,
|
||||
usage: {
|
||||
@@ -159,7 +193,15 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = (await req.json()) as RequestBody
|
||||
|
||||
const { prompt, systemPrompt, stream = false, history = [], workflowId, generationType } = body
|
||||
const {
|
||||
prompt,
|
||||
systemPrompt,
|
||||
stream = false,
|
||||
history = [],
|
||||
workflowId,
|
||||
generationType,
|
||||
wandContext = {},
|
||||
} = body
|
||||
|
||||
if (!prompt) {
|
||||
logger.warn(`[${requestId}] Invalid request: Missing prompt.`)
|
||||
@@ -227,20 +269,15 @@ export async function POST(req: NextRequest) {
|
||||
systemPrompt ||
|
||||
'You are a helpful AI assistant. Generate content exactly as requested by the user.'
|
||||
|
||||
if (generationType === 'timestamp') {
|
||||
const now = new Date()
|
||||
const currentTimeContext = `\n\nCurrent date and time context for reference:
|
||||
- Current UTC timestamp: ${now.toISOString()}
|
||||
- Current Unix timestamp (seconds): ${Math.floor(now.getTime() / 1000)}
|
||||
- Current Unix timestamp (milliseconds): ${now.getTime()}
|
||||
- Current date (UTC): ${now.toISOString().split('T')[0]}
|
||||
- Current year: ${now.getUTCFullYear()}
|
||||
- Current month: ${now.getUTCMonth() + 1}
|
||||
- Current day of month: ${now.getUTCDate()}
|
||||
- Current day of week: ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][now.getUTCDay()]}
|
||||
|
||||
Use this context to calculate relative dates like "yesterday", "last week", "beginning of this month", etc.`
|
||||
finalSystemPrompt += currentTimeContext
|
||||
// Apply enricher if one exists for this generationType
|
||||
if (generationType) {
|
||||
const enricher = wandEnrichers[generationType]
|
||||
if (enricher) {
|
||||
const enrichment = await enricher(workspaceId, wandContext)
|
||||
if (enrichment) {
|
||||
finalSystemPrompt += `\n\n${enrichment}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (generationType === 'json-object') {
|
||||
|
||||
@@ -12,6 +12,10 @@ import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { processInputFileFields } from '@/lib/execution/files'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import {
|
||||
cleanupExecutionBase64Cache,
|
||||
hydrateUserFilesWithBase64,
|
||||
} from '@/lib/uploads/utils/user-file-base64.server'
|
||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
@@ -25,7 +29,7 @@ import type { WorkflowExecutionPayload } from '@/background/workflow-execution'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata, IterationContext } from '@/executor/execution/types'
|
||||
import type { StreamingExecution } from '@/executor/types'
|
||||
import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types'
|
||||
import { Serializer } from '@/serializer'
|
||||
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
|
||||
|
||||
@@ -38,6 +42,8 @@ const ExecuteWorkflowSchema = z.object({
|
||||
useDraftState: z.boolean().optional(),
|
||||
input: z.any().optional(),
|
||||
isClientSession: z.boolean().optional(),
|
||||
includeFileBase64: z.boolean().optional().default(true),
|
||||
base64MaxBytes: z.number().int().positive().optional(),
|
||||
workflowStateOverride: z
|
||||
.object({
|
||||
blocks: z.record(z.any()),
|
||||
@@ -214,6 +220,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
useDraftState,
|
||||
input: validatedInput,
|
||||
isClientSession = false,
|
||||
includeFileBase64,
|
||||
base64MaxBytes,
|
||||
workflowStateOverride,
|
||||
} = validation.data
|
||||
|
||||
@@ -227,6 +235,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
triggerType,
|
||||
stream,
|
||||
useDraftState,
|
||||
includeFileBase64,
|
||||
base64MaxBytes,
|
||||
workflowStateOverride,
|
||||
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
|
||||
...rest
|
||||
@@ -427,16 +437,31 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
snapshot,
|
||||
callbacks: {},
|
||||
loggingSession,
|
||||
includeFileBase64,
|
||||
base64MaxBytes,
|
||||
})
|
||||
|
||||
const hasResponseBlock = workflowHasResponseBlock(result)
|
||||
const outputWithBase64 = includeFileBase64
|
||||
? ((await hydrateUserFilesWithBase64(result.output, {
|
||||
requestId,
|
||||
executionId,
|
||||
maxBytes: base64MaxBytes,
|
||||
})) as NormalizedBlockOutput)
|
||||
: result.output
|
||||
|
||||
const resultWithBase64 = { ...result, output: outputWithBase64 }
|
||||
|
||||
// Cleanup base64 cache for this execution
|
||||
await cleanupExecutionBase64Cache(executionId)
|
||||
|
||||
const hasResponseBlock = workflowHasResponseBlock(resultWithBase64)
|
||||
if (hasResponseBlock) {
|
||||
return createHttpResponseFromBlock(result)
|
||||
return createHttpResponseFromBlock(resultWithBase64)
|
||||
}
|
||||
|
||||
const filteredResult = {
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
output: outputWithBase64,
|
||||
error: result.error,
|
||||
metadata: result.metadata
|
||||
? {
|
||||
@@ -498,6 +523,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
selectedOutputs: resolvedSelectedOutputs,
|
||||
isSecureMode: false,
|
||||
workflowTriggerType: triggerType === 'chat' ? 'chat' : 'api',
|
||||
includeFileBase64,
|
||||
base64MaxBytes,
|
||||
},
|
||||
executionId,
|
||||
})
|
||||
@@ -698,6 +725,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
},
|
||||
loggingSession,
|
||||
abortSignal: abortController.signal,
|
||||
includeFileBase64,
|
||||
base64MaxBytes,
|
||||
})
|
||||
|
||||
if (result.status === 'paused') {
|
||||
@@ -750,12 +779,21 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
workflowId,
|
||||
data: {
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
output: includeFileBase64
|
||||
? await hydrateUserFilesWithBase64(result.output, {
|
||||
requestId,
|
||||
executionId,
|
||||
maxBytes: base64MaxBytes,
|
||||
})
|
||||
: result.output,
|
||||
duration: result.metadata?.duration || 0,
|
||||
startTime: result.metadata?.startTime || startTime.toISOString(),
|
||||
endTime: result.metadata?.endTime || new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
// Cleanup base64 cache for this execution
|
||||
await cleanupExecutionBase64Cache(executionId)
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Unknown error'
|
||||
logger.error(`[${requestId}] SSE execution failed: ${errorMessage}`)
|
||||
|
||||
@@ -33,6 +33,7 @@ const BlockDataSchema = z.object({
|
||||
doWhileCondition: z.string().optional(),
|
||||
parallelType: z.enum(['collection', 'count']).optional(),
|
||||
type: z.string().optional(),
|
||||
canonicalModes: z.record(z.enum(['basic', 'advanced'])).optional(),
|
||||
})
|
||||
|
||||
const SubBlockStateSchema = z.object({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isUserFile } from '@/lib/core/utils/display-filters'
|
||||
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
|
||||
import type { ChatFile, ChatMessage } from '@/app/chat/components/message/message'
|
||||
import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants'
|
||||
|
||||
@@ -17,7 +17,7 @@ function extractFilesFromData(
|
||||
return files
|
||||
}
|
||||
|
||||
if (isUserFile(data)) {
|
||||
if (isUserFileWithMetadata(data)) {
|
||||
if (!seenIds.has(data.id)) {
|
||||
seenIds.add(data.id)
|
||||
files.push({
|
||||
@@ -232,7 +232,7 @@ export function useChatStreaming() {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isUserFile(value)) {
|
||||
if (isUserFileWithMetadata(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ export function useChatStreaming() {
|
||||
|
||||
const value = getOutputValue(blockOutputs, config.path)
|
||||
|
||||
if (isUserFile(value)) {
|
||||
if (isUserFileWithMetadata(value)) {
|
||||
extractedFiles.push({
|
||||
id: value.id,
|
||||
name: value.name,
|
||||
|
||||
@@ -555,7 +555,7 @@ export function DocumentTagsModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant={canSaveTag ? 'tertiary' : 'default'}
|
||||
onClick={saveDocumentTag}
|
||||
className='flex-1'
|
||||
disabled={!canSaveTag}
|
||||
|
||||
@@ -300,7 +300,7 @@ export function EditChunkModal({
|
||||
</Button>
|
||||
{userPermissions.canEdit && (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant={hasUnsavedChanges ? 'tertiary' : 'default'}
|
||||
onClick={handleSaveContent}
|
||||
type='button'
|
||||
disabled={!isFormValid || isSaving || !hasUnsavedChanges || isNavigating}
|
||||
|
||||
@@ -538,15 +538,11 @@ export function Document({
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (operation === 'delete') {
|
||||
if (operation === 'delete' || result.errorCount > 0) {
|
||||
refreshChunks()
|
||||
} else {
|
||||
result.results.forEach((opResult) => {
|
||||
if (opResult.operation === operation) {
|
||||
opResult.chunkIds.forEach((chunkId: string) => {
|
||||
updateChunk(chunkId, { enabled: operation === 'enable' })
|
||||
})
|
||||
}
|
||||
chunks.forEach((chunk) => {
|
||||
updateChunk(chunk.id, { enabled: operation === 'enable' })
|
||||
})
|
||||
}
|
||||
logger.info(`Successfully ${operation}d ${result.successCount} chunks`)
|
||||
|
||||
@@ -462,7 +462,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='space-y-[8px]'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
{selectedTagUsage?.documentCount || 0} document
|
||||
{selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag
|
||||
definition.
|
||||
@@ -470,7 +470,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
|
||||
{selectedTagUsage?.documentCount === 0 ? (
|
||||
<div className='rounded-[6px] border p-[16px] text-center'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
This tag definition is not being used by any documents. You can safely delete it
|
||||
to free up the tag slot.
|
||||
</p>
|
||||
|
||||
@@ -39,6 +39,9 @@ export function RenameDocumentModal({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Check if name has changed from initial value
|
||||
const hasChanges = name.trim() !== initialName.trim()
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initialName)
|
||||
@@ -123,7 +126,11 @@ export function RenameDocumentModal({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' type='submit' disabled={isSubmitting || !name?.trim()}>
|
||||
<Button
|
||||
variant={hasChanges ? 'tertiary' : 'default'}
|
||||
type='submit'
|
||||
disabled={isSubmitting || !name?.trim() || !hasChanges}
|
||||
>
|
||||
{isSubmitting ? 'Renaming...' : 'Rename'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Badge, DocumentAttachment, Tooltip } from '@/components/emcn'
|
||||
import { Badge, Button, DocumentAttachment, Tooltip } from '@/components/emcn'
|
||||
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
@@ -143,6 +143,7 @@ export function BaseCard({
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
kbName: title,
|
||||
@@ -151,6 +152,23 @@ export function BaseCard({
|
||||
|
||||
const shortId = id ? `kb-${id.slice(0, 8)}` : ''
|
||||
|
||||
const handleMenuButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (menuButtonRef.current) {
|
||||
const rect = menuButtonRef.current.getBoundingClientRect()
|
||||
const syntheticEvent = {
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
clientX: rect.right,
|
||||
clientY: rect.bottom,
|
||||
} as React.MouseEvent
|
||||
handleContextMenu(syntheticEvent)
|
||||
}
|
||||
},
|
||||
[handleContextMenu]
|
||||
)
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isContextMenuOpen) {
|
||||
@@ -223,9 +241,24 @@ export function BaseCard({
|
||||
<h3 className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
{title}
|
||||
</h3>
|
||||
{shortId && (
|
||||
<Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>
|
||||
)}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{shortId && (
|
||||
<Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>
|
||||
)}
|
||||
<Button
|
||||
ref={menuButtonRef}
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-[20px] w-[20px] flex-shrink-0 p-0 text-[var(--text-tertiary)]'
|
||||
onClick={handleMenuButtonClick}
|
||||
>
|
||||
<svg className='h-[14px] w-[14px]' viewBox='0 0 16 16' fill='currentColor'>
|
||||
<circle cx='3' cy='8' r='1.5' />
|
||||
<circle cx='8' cy='8' r='1.5' />
|
||||
<circle cx='13' cy='8' r='1.5' />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 flex-col gap-[8px]'>
|
||||
|
||||
@@ -70,6 +70,12 @@ export function EditKnowledgeBaseModal({
|
||||
})
|
||||
|
||||
const nameValue = watch('name')
|
||||
const descriptionValue = watch('description')
|
||||
|
||||
// Check if form values have changed from initial values
|
||||
const hasChanges =
|
||||
nameValue?.trim() !== initialName.trim() ||
|
||||
(descriptionValue?.trim() || '') !== (initialDescription?.trim() || '')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -159,9 +165,9 @@ export function EditKnowledgeBaseModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant={hasChanges ? 'tertiary' : 'default'}
|
||||
type='submit'
|
||||
disabled={isSubmitting || !nameValue?.trim()}
|
||||
disabled={isSubmitting || !nameValue?.trim() || !hasChanges}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
|
||||
@@ -265,12 +265,11 @@ export function Knowledge() {
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
Error loading knowledge bases
|
||||
</p>
|
||||
<p className='mt-1 text-[var(--text-muted)] text-xs'>{error}</p>
|
||||
<div className='col-span-full flex h-64 items-center justify-center'>
|
||||
<div className='text-[var(--text-error)]'>
|
||||
<span className='text-[13px]'>
|
||||
Error: {typeof error === 'string' ? error : 'Failed to load knowledge bases'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -234,7 +234,7 @@ function ProgressBar({
|
||||
{segments.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='absolute h-full'
|
||||
className='absolute h-full opacity-70'
|
||||
style={{
|
||||
left: `${segment.startPercent}%`,
|
||||
width: `${segment.widthPercent}%`,
|
||||
|
||||
@@ -257,7 +257,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
Version
|
||||
</span>
|
||||
<div className='flex w-0 flex-1 justify-end'>
|
||||
<span className='max-w-full truncate rounded-[6px] bg-[#14291B] px-[9px] py-[2px] font-medium text-[#86EFAC] text-[12px]'>
|
||||
<span className='max-w-full truncate rounded-[6px] bg-[#bbf7d0] px-[9px] py-[2px] font-medium text-[#15803d] text-[12px] dark:bg-[#14291B] dark:text-[#86EFAC]'>
|
||||
{log.deploymentVersionName || `v${log.deploymentVersion}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { DatePicker } from '@/components/emcn/components/date-picker/date-picker
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { hasActiveFilters } from '@/lib/logs/filters'
|
||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||
import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
@@ -211,12 +212,12 @@ export function LogsToolbar({
|
||||
}, [level])
|
||||
|
||||
const statusOptions: ComboboxOption[] = useMemo(
|
||||
() => [
|
||||
{ value: 'error', label: 'Error', icon: getColorIcon('var(--text-error)') },
|
||||
{ value: 'info', label: 'Info', icon: getColorIcon('var(--terminal-status-info-color)') },
|
||||
{ value: 'running', label: 'Running', icon: getColorIcon('#22c55e') },
|
||||
{ value: 'pending', label: 'Pending', icon: getColorIcon('#f59e0b') },
|
||||
],
|
||||
() =>
|
||||
(Object.keys(STATUS_CONFIG) as LogStatus[]).map((status) => ({
|
||||
value: status,
|
||||
label: STATUS_CONFIG[status].label,
|
||||
icon: getColorIcon(STATUS_CONFIG[status].color),
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
@@ -242,12 +243,8 @@ export function LogsToolbar({
|
||||
|
||||
const selectedStatusColor = useMemo(() => {
|
||||
if (selectedStatuses.length !== 1) return null
|
||||
const status = selectedStatuses[0]
|
||||
if (status === 'error') return 'var(--text-error)'
|
||||
if (status === 'info') return 'var(--terminal-status-info-color)'
|
||||
if (status === 'running') return '#22c55e'
|
||||
if (status === 'pending') return '#f59e0b'
|
||||
return null
|
||||
const status = selectedStatuses[0] as LogStatus
|
||||
return STATUS_CONFIG[status]?.color ?? null
|
||||
}, [selectedStatuses])
|
||||
|
||||
const workflowOptions: ComboboxOption[] = useMemo(
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
|
||||
/** Column configuration for logs table - shared between header and rows */
|
||||
export const LOG_COLUMNS = {
|
||||
date: { width: 'w-[8%]', minWidth: 'min-w-[70px]', label: 'Date' },
|
||||
time: { width: 'w-[12%]', minWidth: 'min-w-[90px]', label: 'Time' },
|
||||
@@ -16,10 +15,8 @@ export const LOG_COLUMNS = {
|
||||
duration: { width: 'w-[20%]', minWidth: 'min-w-[100px]', label: 'Duration' },
|
||||
} as const
|
||||
|
||||
/** Type-safe column key derived from LOG_COLUMNS */
|
||||
export type LogColumnKey = keyof typeof LOG_COLUMNS
|
||||
|
||||
/** Ordered list of column keys for rendering table headers */
|
||||
export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
|
||||
'date',
|
||||
'time',
|
||||
@@ -30,7 +27,6 @@ export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
|
||||
'duration',
|
||||
] as const
|
||||
|
||||
/** Possible execution status values for workflow logs */
|
||||
export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled'
|
||||
|
||||
/**
|
||||
@@ -53,30 +49,28 @@ export function getDisplayStatus(status: string | null | undefined): LogStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/** Configuration mapping log status to Badge variant and display label */
|
||||
const STATUS_VARIANT_MAP: Record<
|
||||
export const STATUS_CONFIG: Record<
|
||||
LogStatus,
|
||||
{ variant: React.ComponentProps<typeof Badge>['variant']; label: string }
|
||||
{ variant: React.ComponentProps<typeof Badge>['variant']; label: string; color: string }
|
||||
> = {
|
||||
error: { variant: 'red', label: 'Error' },
|
||||
pending: { variant: 'amber', label: 'Pending' },
|
||||
running: { variant: 'green', label: 'Running' },
|
||||
cancelled: { variant: 'gray', label: 'Cancelled' },
|
||||
info: { variant: 'gray', label: 'Info' },
|
||||
error: { variant: 'red', label: 'Error', color: 'var(--text-error)' },
|
||||
pending: { variant: 'amber', label: 'Pending', color: '#f59e0b' },
|
||||
running: { variant: 'green', label: 'Running', color: '#22c55e' },
|
||||
cancelled: { variant: 'orange', label: 'Cancelled', color: '#f97316' },
|
||||
info: { variant: 'gray', label: 'Info', color: 'var(--terminal-status-info-color)' },
|
||||
}
|
||||
|
||||
/** Configuration mapping core trigger types to Badge color variants */
|
||||
const TRIGGER_VARIANT_MAP: Record<string, React.ComponentProps<typeof Badge>['variant']> = {
|
||||
manual: 'gray-secondary',
|
||||
api: 'blue',
|
||||
schedule: 'green',
|
||||
chat: 'purple',
|
||||
webhook: 'orange',
|
||||
mcp: 'cyan',
|
||||
a2a: 'teal',
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
/** The execution status to display */
|
||||
status: LogStatus
|
||||
}
|
||||
|
||||
@@ -86,14 +80,13 @@ interface StatusBadgeProps {
|
||||
* @returns A Badge with dot indicator and status label
|
||||
*/
|
||||
export const StatusBadge = React.memo(({ status }: StatusBadgeProps) => {
|
||||
const config = STATUS_VARIANT_MAP[status]
|
||||
const config = STATUS_CONFIG[status]
|
||||
return React.createElement(Badge, { variant: config.variant, dot: true }, config.label)
|
||||
})
|
||||
|
||||
StatusBadge.displayName = 'StatusBadge'
|
||||
|
||||
interface TriggerBadgeProps {
|
||||
/** The trigger type identifier (e.g., 'manual', 'api', or integration block type) */
|
||||
trigger: string
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Button, TableCell, TableRow } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
|
||||
interface LoadingRowsProps {
|
||||
columns: ColumnDefinition[]
|
||||
}
|
||||
|
||||
export function LoadingRows({ columns }: LoadingRowsProps) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 25 }).map((_, rowIndex) => (
|
||||
<TableRow key={rowIndex}>
|
||||
<TableCell>
|
||||
<Skeleton className='h-[14px] w-[14px]' />
|
||||
</TableCell>
|
||||
{columns.map((col, colIndex) => {
|
||||
const baseWidth =
|
||||
col.type === 'json'
|
||||
? 200
|
||||
: col.type === 'string'
|
||||
? 160
|
||||
: col.type === 'number'
|
||||
? 80
|
||||
: col.type === 'boolean'
|
||||
? 50
|
||||
: col.type === 'date'
|
||||
? 100
|
||||
: 120
|
||||
const variation = ((rowIndex + colIndex) % 3) * 20
|
||||
const width = baseWidth + variation
|
||||
|
||||
return (
|
||||
<TableCell key={col.name}>
|
||||
<Skeleton className='h-[16px]' style={{ width: `${width}px` }} />
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface EmptyRowsProps {
|
||||
columnCount: number
|
||||
hasFilter: boolean
|
||||
onAddRow: () => void
|
||||
}
|
||||
|
||||
export function EmptyRows({ columnCount, hasFilter, onAddRow }: EmptyRowsProps) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columnCount + 1} className='h-[160px]'>
|
||||
<div className='flex h-full w-full items-center justify-center'>
|
||||
<div className='flex flex-col items-center gap-[12px]'>
|
||||
<span className='text-[13px] text-[var(--text-tertiary)]'>
|
||||
{hasFilter ? 'No rows match your filter' : 'No data'}
|
||||
</span>
|
||||
{!hasFilter && (
|
||||
<Button variant='default' size='sm' onClick={onAddRow}>
|
||||
<Plus className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Add first row
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
import { STRING_TRUNCATE_LENGTH } from '../lib/constants'
|
||||
import type { CellViewerData } from '../lib/types'
|
||||
|
||||
interface CellRendererProps {
|
||||
value: unknown
|
||||
column: ColumnDefinition
|
||||
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
|
||||
}
|
||||
|
||||
export function CellRenderer({ value, column, onCellClick }: CellRendererProps) {
|
||||
const isNull = value === null || value === undefined
|
||||
|
||||
if (isNull) {
|
||||
return <span className='text-[var(--text-muted)] italic'>—</span>
|
||||
}
|
||||
|
||||
if (column.type === 'json') {
|
||||
const jsonStr = JSON.stringify(value)
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className='block max-w-[300px] cursor-pointer select-none truncate rounded-[4px] border border-[var(--border-1)] px-[6px] py-[2px] text-left font-mono text-[11px] text-[var(--text-secondary)] transition-colors hover:border-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onCellClick(column.name, value, 'json')
|
||||
}}
|
||||
title='Click to view full JSON'
|
||||
>
|
||||
{jsonStr}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (column.type === 'boolean') {
|
||||
const boolValue = Boolean(value)
|
||||
return (
|
||||
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
|
||||
{boolValue ? 'true' : 'false'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (column.type === 'number') {
|
||||
return (
|
||||
<span className='font-mono text-[12px] text-[var(--text-secondary)]'>{String(value)}</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (column.type === 'date') {
|
||||
try {
|
||||
const date = new Date(String(value))
|
||||
const formatted = date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className='cursor-pointer select-none text-left text-[12px] text-[var(--text-secondary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:text-[var(--text-primary)] hover:decoration-[var(--text-muted)]'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onCellClick(column.name, value, 'date')
|
||||
}}
|
||||
title='Click to view ISO format'
|
||||
>
|
||||
{formatted}
|
||||
</button>
|
||||
)
|
||||
} catch {
|
||||
return <span className='text-[var(--text-primary)]'>{String(value)}</span>
|
||||
}
|
||||
}
|
||||
|
||||
const strValue = String(value)
|
||||
if (strValue.length > STRING_TRUNCATE_LENGTH) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className='block max-w-[300px] cursor-pointer select-none truncate text-left text-[var(--text-primary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:decoration-[var(--text-muted)]'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onCellClick(column.name, value, 'text')
|
||||
}}
|
||||
title='Click to view full text'
|
||||
>
|
||||
{strValue}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className='text-[var(--text-primary)]'>{strValue}</span>
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Copy, X } from 'lucide-react'
|
||||
import { Badge, Button, Modal, ModalBody, ModalContent } from '@/components/emcn'
|
||||
import type { CellViewerData } from '../lib/types'
|
||||
|
||||
interface CellViewerModalProps {
|
||||
cellViewer: CellViewerData | null
|
||||
onClose: () => void
|
||||
onCopy: () => void
|
||||
copied: boolean
|
||||
}
|
||||
|
||||
export function CellViewerModal({ cellViewer, onClose, onCopy, copied }: CellViewerModalProps) {
|
||||
if (!cellViewer) return null
|
||||
|
||||
return (
|
||||
<Modal open={!!cellViewer} onOpenChange={(open) => !open && onClose()}>
|
||||
<ModalContent className='w-[640px] duration-100'>
|
||||
<div className='flex items-center justify-between gap-[8px] px-[16px] py-[10px]'>
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
<span className='truncate font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
{cellViewer.columnName}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
cellViewer.type === 'json' ? 'blue' : cellViewer.type === 'date' ? 'purple' : 'gray'
|
||||
}
|
||||
size='sm'
|
||||
>
|
||||
{cellViewer.type === 'json' ? 'JSON' : cellViewer.type === 'date' ? 'Date' : 'Text'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-[8px]'>
|
||||
<Button variant={copied ? 'tertiary' : 'default'} size='sm' onClick={onCopy}>
|
||||
<Copy className='mr-[4px] h-[12px] w-[12px]' />
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
<Button variant='ghost' size='sm' onClick={onClose}>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ModalBody className='p-0'>
|
||||
{cellViewer.type === 'json' ? (
|
||||
<pre className='m-[16px] max-h-[450px] overflow-auto rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] font-mono text-[12px] text-[var(--text-primary)] leading-[1.6]'>
|
||||
{JSON.stringify(cellViewer.value, null, 2)}
|
||||
</pre>
|
||||
) : cellViewer.type === 'date' ? (
|
||||
<div className='m-[16px] space-y-[12px]'>
|
||||
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
|
||||
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
|
||||
Formatted
|
||||
</div>
|
||||
<div className='text-[14px] text-[var(--text-primary)]'>
|
||||
{new Date(String(cellViewer.value)).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
|
||||
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
|
||||
ISO Format
|
||||
</div>
|
||||
<div className='font-mono text-[13px] text-[var(--text-secondary)]'>
|
||||
{String(cellViewer.value)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='m-[16px] max-h-[450px] overflow-auto whitespace-pre-wrap break-words rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] text-[13px] text-[var(--text-primary)] leading-[1.7]'>
|
||||
{String(cellViewer.value)}
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Edit, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import type { ContextMenuState } from '../lib/types'
|
||||
|
||||
interface ContextMenuProps {
|
||||
contextMenu: ContextMenuState
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function ContextMenu({ contextMenu, onClose, onEdit, onDelete }: ContextMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
open={contextMenu.isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${contextMenu.position.x}px`,
|
||||
top: `${contextMenu.position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent align='start' side='bottom' sideOffset={4}>
|
||||
<PopoverItem onClick={onEdit}>
|
||||
<Edit className='mr-[8px] h-[12px] w-[12px]' />
|
||||
Edit row
|
||||
</PopoverItem>
|
||||
<PopoverDivider />
|
||||
<PopoverItem onClick={onDelete} className='text-[var(--text-error)]'>
|
||||
<Trash2 className='mr-[8px] h-[12px] w-[12px]' />
|
||||
Delete row
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Checkbox, Input, Textarea } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
|
||||
interface EditableCellProps {
|
||||
value: unknown
|
||||
column: ColumnDefinition
|
||||
onChange: (value: unknown) => void
|
||||
isEditing?: boolean
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
function formatValueForDisplay(value: unknown, type: string): string {
|
||||
if (value === null || value === undefined) return 'NULL'
|
||||
if (type === 'json') {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value)
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
return value ? 'TRUE' : 'FALSE'
|
||||
}
|
||||
if (type === 'date' && value) {
|
||||
try {
|
||||
const date = new Date(String(value))
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatValueForInput(value: unknown, type: string): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (type === 'json') {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
}
|
||||
if (type === 'date' && value) {
|
||||
try {
|
||||
const date = new Date(String(value))
|
||||
return date.toISOString().split('T')[0]
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function EditableCell({
|
||||
value,
|
||||
column,
|
||||
onChange,
|
||||
isEditing = false,
|
||||
isNew = false,
|
||||
}: EditableCellProps) {
|
||||
const [localValue, setLocalValue] = useState<unknown>(value)
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value)
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsActive(true)
|
||||
}, [])
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsActive(false)
|
||||
if (localValue !== value) {
|
||||
onChange(localValue)
|
||||
}
|
||||
}, [localValue, value, onChange])
|
||||
|
||||
const handleChange = useCallback((newValue: unknown) => {
|
||||
setLocalValue(newValue)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && column.type !== 'json') {
|
||||
e.preventDefault()
|
||||
;(e.target as HTMLElement).blur()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setLocalValue(value)
|
||||
;(e.target as HTMLElement).blur()
|
||||
}
|
||||
},
|
||||
[value, column.type]
|
||||
)
|
||||
|
||||
const isNull = value === null || value === undefined
|
||||
|
||||
// Boolean type - always show checkbox
|
||||
if (column.type === 'boolean') {
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={Boolean(localValue)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked === true
|
||||
setLocalValue(newValue)
|
||||
onChange(newValue)
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-[8px] text-[12px]',
|
||||
localValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'
|
||||
)}
|
||||
>
|
||||
{localValue ? 'TRUE' : 'FALSE'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// JSON type - use textarea
|
||||
if (column.type === 'json') {
|
||||
if (isActive || isNew) {
|
||||
return (
|
||||
<Textarea
|
||||
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||
value={formatValueForInput(localValue, column.type)}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='h-[60px] min-w-[200px] resize-none font-mono text-[11px]'
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleFocus}
|
||||
className={cn(
|
||||
'group flex max-w-[300px] cursor-pointer items-center truncate text-left font-mono text-[11px] transition-colors',
|
||||
isNull
|
||||
? 'text-[var(--text-muted)] italic'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
<span className='truncate'>{formatValueForDisplay(value, column.type)}</span>
|
||||
<ChevronRight className='ml-[4px] h-[10px] w-[10px] opacity-0 group-hover:opacity-100' />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Active/editing state for other types
|
||||
if (isActive || isNew) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||
type={column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'}
|
||||
value={formatValueForInput(localValue, column.type)}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'h-[28px] min-w-[120px] text-[12px]',
|
||||
column.type === 'number' && 'font-mono'
|
||||
)}
|
||||
placeholder={isNull ? 'NULL' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Display state
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleFocus}
|
||||
className={cn(
|
||||
'group flex max-w-[300px] cursor-pointer items-center truncate text-left text-[13px] transition-colors',
|
||||
isNull
|
||||
? 'text-[var(--text-muted)] italic'
|
||||
: column.type === 'number'
|
||||
? 'font-mono text-[12px] text-[var(--text-secondary)]'
|
||||
: 'text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
<span className='truncate'>{formatValueForDisplay(value, column.type)}</span>
|
||||
<ChevronRight className='ml-[4px] h-[10px] w-[10px] opacity-0 group-hover:opacity-100' />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { X } from 'lucide-react'
|
||||
import { Button, TableCell, TableRow } from '@/components/emcn'
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
import type { TempRow } from '../hooks/use-inline-editing'
|
||||
import { EditableCell } from './editable-cell'
|
||||
|
||||
interface EditableRowProps {
|
||||
row: TempRow
|
||||
columns: ColumnDefinition[]
|
||||
onUpdateCell: (tempId: string, column: string, value: unknown) => void
|
||||
onRemove: (tempId: string) => void
|
||||
}
|
||||
|
||||
export function EditableRow({ row, columns, onUpdateCell, onRemove }: EditableRowProps) {
|
||||
return (
|
||||
<TableRow className='bg-amber-500/20 hover:bg-amber-500/30'>
|
||||
<TableCell className='w-[40px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => onRemove(row.tempId)}
|
||||
className='h-[20px] w-[20px] p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<X className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.name}>
|
||||
<EditableCell
|
||||
value={row.data[column.name]}
|
||||
column={column}
|
||||
onChange={(value) => onUpdateCell(row.tempId, column.name, value)}
|
||||
isNew
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { Button, Combobox, Input } from '@/components/emcn'
|
||||
import type { FilterRule } from '@/lib/table/query-builder/constants'
|
||||
import { filterRulesToFilter } from '@/lib/table/query-builder/converters'
|
||||
import { useFilterBuilder } from '@/lib/table/query-builder/use-query-builder'
|
||||
import type { ColumnDefinition } from '@/lib/table/types'
|
||||
import type { QueryOptions } from '../lib/types'
|
||||
|
||||
type Column = Pick<ColumnDefinition, 'name' | 'type'>
|
||||
|
||||
interface FilterPanelProps {
|
||||
columns: Column[]
|
||||
isVisible: boolean
|
||||
onApply: (options: QueryOptions) => void
|
||||
onClose: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
// Operators that don't need a value input
|
||||
const NO_VALUE_OPERATORS = ['is_null', 'is_not_null']
|
||||
|
||||
// Options for the first filter row
|
||||
const WHERE_OPTIONS = [{ value: 'where', label: 'where' }]
|
||||
|
||||
export function FilterPanel({
|
||||
columns,
|
||||
isVisible,
|
||||
onApply,
|
||||
onClose,
|
||||
isLoading = false,
|
||||
}: FilterPanelProps) {
|
||||
const [rules, setRules] = useState<FilterRule[]>([])
|
||||
|
||||
const columnOptions = useMemo(
|
||||
() => columns.map((col) => ({ value: col.name, label: col.name })),
|
||||
[columns]
|
||||
)
|
||||
|
||||
const {
|
||||
comparisonOptions,
|
||||
logicalOptions,
|
||||
addRule: handleAddRule,
|
||||
removeRule: handleRemoveRule,
|
||||
updateRule: handleUpdateRule,
|
||||
} = useFilterBuilder({
|
||||
columns: columnOptions,
|
||||
rules,
|
||||
setRules,
|
||||
})
|
||||
|
||||
// Auto-add first filter when panel opens with no filters
|
||||
useEffect(() => {
|
||||
if (isVisible && rules.length === 0 && columns.length > 0) {
|
||||
handleAddRule()
|
||||
}
|
||||
}, [isVisible, rules.length, columns.length, handleAddRule])
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const filter = filterRulesToFilter(rules)
|
||||
onApply({ filter, sort: null })
|
||||
}, [rules, onApply])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setRules([])
|
||||
onApply({ filter: null, sort: null })
|
||||
onClose()
|
||||
}, [onApply, onClose])
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex shrink-0 flex-col gap-2 border-[var(--border)] border-b px-4 py-3'>
|
||||
{rules.map((rule, index) => {
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(rule.operator)
|
||||
const isFirst = index === 0
|
||||
|
||||
return (
|
||||
<div key={rule.id} className='flex items-center gap-2'>
|
||||
{/* Remove button */}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleRemoveRule(rule.id)}
|
||||
aria-label='Remove filter'
|
||||
className='shrink-0 p-1'
|
||||
>
|
||||
<X className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
|
||||
{/* Where / And / Or */}
|
||||
<div className='w-20 shrink-0'>
|
||||
{isFirst ? (
|
||||
<Combobox size='sm' options={WHERE_OPTIONS} value='where' onChange={() => {}} />
|
||||
) : (
|
||||
<Combobox
|
||||
size='sm'
|
||||
options={logicalOptions}
|
||||
value={rule.logicalOperator}
|
||||
onChange={(value) =>
|
||||
handleUpdateRule(rule.id, 'logicalOperator', value as 'and' | 'or')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column */}
|
||||
<div className='w-[140px] shrink-0'>
|
||||
<Combobox
|
||||
size='sm'
|
||||
options={columnOptions}
|
||||
value={rule.column}
|
||||
onChange={(value) => handleUpdateRule(rule.id, 'column', value)}
|
||||
placeholder='Column'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Operator */}
|
||||
<div className='w-[120px] shrink-0'>
|
||||
<Combobox
|
||||
size='sm'
|
||||
options={comparisonOptions}
|
||||
value={rule.operator}
|
||||
onChange={(value) => handleUpdateRule(rule.id, 'operator', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value (only if operator needs it) */}
|
||||
{needsValue && (
|
||||
<Input
|
||||
className='w-[160px] shrink-0'
|
||||
value={rule.value}
|
||||
onChange={(e) => handleUpdateRule(rule.id, 'value', e.target.value)}
|
||||
placeholder='Enter a value'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleApply()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions - only on first row */}
|
||||
{isFirst && (
|
||||
<div className='ml-1 flex items-center gap-1'>
|
||||
<Button variant='tertiary' size='sm' onClick={handleApply} disabled={isLoading}>
|
||||
Apply
|
||||
</Button>
|
||||
|
||||
<Button variant='ghost' size='sm' onClick={handleAddRule}>
|
||||
<Plus className='h-3 w-3' />
|
||||
Add filter
|
||||
</Button>
|
||||
|
||||
<Button variant='ghost' size='sm' onClick={handleClear}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from './body-states'
|
||||
export * from './cell-renderer'
|
||||
export * from './cell-viewer-modal'
|
||||
export * from './context-menu'
|
||||
export * from './editable-cell'
|
||||
export * from './editable-row'
|
||||
export * from './filter-panel'
|
||||
export * from './row-modal'
|
||||
export * from './schema-modal'
|
||||
export * from './table-toolbar'
|
||||
export * from './table-viewer'
|
||||
@@ -0,0 +1,399 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
|
||||
|
||||
const logger = createLogger('RowModal')
|
||||
|
||||
export interface RowModalProps {
|
||||
mode: 'add' | 'edit' | 'delete'
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
table: TableInfo
|
||||
row?: TableRow
|
||||
rowIds?: string[]
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function createInitialRowData(columns: ColumnDefinition[]): Record<string, unknown> {
|
||||
const initial: Record<string, unknown> = {}
|
||||
columns.forEach((col) => {
|
||||
if (col.type === 'boolean') {
|
||||
initial[col.name] = false
|
||||
} else {
|
||||
initial[col.name] = ''
|
||||
}
|
||||
})
|
||||
return initial
|
||||
}
|
||||
|
||||
function cleanRowData(
|
||||
columns: ColumnDefinition[],
|
||||
rowData: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const cleanData: Record<string, unknown> = {}
|
||||
|
||||
columns.forEach((col) => {
|
||||
const value = rowData[col.name]
|
||||
if (col.type === 'number') {
|
||||
cleanData[col.name] = value === '' ? null : Number(value)
|
||||
} else if (col.type === 'json') {
|
||||
if (typeof value === 'string') {
|
||||
if (value === '') {
|
||||
cleanData[col.name] = null
|
||||
} else {
|
||||
try {
|
||||
cleanData[col.name] = JSON.parse(value)
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON for field: ${col.name}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cleanData[col.name] = value
|
||||
}
|
||||
} else if (col.type === 'boolean') {
|
||||
cleanData[col.name] = Boolean(value)
|
||||
} else {
|
||||
cleanData[col.name] = value || null
|
||||
}
|
||||
})
|
||||
|
||||
return cleanData
|
||||
}
|
||||
|
||||
function formatValueForInput(value: unknown, type: string): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (type === 'json') {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
}
|
||||
if (type === 'date' && value) {
|
||||
try {
|
||||
const date = new Date(String(value))
|
||||
return date.toISOString().split('T')[0]
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function isFieldEmpty(value: unknown, type: string): boolean {
|
||||
if (value === null || value === undefined) return true
|
||||
if (type === 'boolean') return false // booleans always have a value (true/false)
|
||||
if (typeof value === 'string') return value.trim() === ''
|
||||
return false
|
||||
}
|
||||
|
||||
export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess }: RowModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const schema = table?.schema
|
||||
const columns = schema?.columns || []
|
||||
|
||||
const [rowData, setRowData] = useState<Record<string, unknown>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Check if all required fields are filled
|
||||
const hasRequiredFields = useMemo(() => {
|
||||
const requiredColumns = columns.filter((col) => col.required)
|
||||
return requiredColumns.every((col) => !isFieldEmpty(rowData[col.name], col.type))
|
||||
}, [columns, rowData])
|
||||
|
||||
// Initialize form data based on mode
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
if (mode === 'add' && columns.length > 0) {
|
||||
setRowData(createInitialRowData(columns))
|
||||
} else if (mode === 'edit' && row) {
|
||||
setRowData(row.data)
|
||||
}
|
||||
}, [isOpen, mode, columns, row])
|
||||
|
||||
const handleFormSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cleanData = cleanRowData(columns, rowData)
|
||||
|
||||
if (mode === 'add') {
|
||||
const res = await fetch(`/api/table/${table?.id}/rows`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId, data: cleanData }),
|
||||
})
|
||||
|
||||
const result: { error?: string } = await res.json()
|
||||
if (!res.ok) {
|
||||
throw new Error(result.error || 'Failed to add row')
|
||||
}
|
||||
} else if (mode === 'edit' && row) {
|
||||
const res = await fetch(`/api/table/${table?.id}/rows/${row.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId, data: cleanData }),
|
||||
})
|
||||
|
||||
const result: { error?: string } = await res.json()
|
||||
if (!res.ok) {
|
||||
throw new Error(result.error || 'Failed to update row')
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
logger.error(`Failed to ${mode} row:`, err)
|
||||
setError(err instanceof Error ? err.message : `Failed to ${mode} row`)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setError(null)
|
||||
setIsSubmitting(true)
|
||||
|
||||
const idsToDelete = rowIds ?? (row ? [row.id] : [])
|
||||
|
||||
try {
|
||||
if (idsToDelete.length === 1) {
|
||||
const res = await fetch(`/api/table/${table?.id}/rows/${idsToDelete[0]}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const result: { error?: string } = await res.json()
|
||||
throw new Error(result.error || 'Failed to delete row')
|
||||
}
|
||||
} else {
|
||||
const results = await Promise.allSettled(
|
||||
idsToDelete.map(async (rowId) => {
|
||||
const res = await fetch(`/api/table/${table?.id}/rows/${rowId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const result: { error?: string } = await res.json().catch(() => ({}))
|
||||
throw new Error(result.error || `Failed to delete row ${rowId}`)
|
||||
}
|
||||
|
||||
return rowId
|
||||
})
|
||||
)
|
||||
|
||||
const failures = results.filter((r) => r.status === 'rejected')
|
||||
|
||||
if (failures.length > 0) {
|
||||
const failureCount = failures.length
|
||||
const totalCount = idsToDelete.length
|
||||
const successCount = totalCount - failureCount
|
||||
const firstError =
|
||||
failures[0].status === 'rejected' ? failures[0].reason?.message || 'Unknown error' : ''
|
||||
|
||||
throw new Error(
|
||||
`Failed to delete ${failureCount} of ${totalCount} row(s)${successCount > 0 ? ` (${successCount} deleted successfully)` : ''}. ${firstError}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete row(s):', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete row(s)')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setRowData({})
|
||||
setError(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
// Delete mode UI
|
||||
if (mode === 'delete') {
|
||||
const deleteCount = rowIds?.length ?? (row ? 1 : 0)
|
||||
const isSingleRow = deleteCount === 1
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={handleClose}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete {isSingleRow ? 'Row' : `${deleteCount} Rows`}</ModalHeader>
|
||||
<ModalBody>
|
||||
<ErrorMessage error={error} />
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{isSingleRow ? '1 row' : `${deleteCount} rows`}
|
||||
</span>
|
||||
? This will permanently remove the data.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={handleClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDelete} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const isAddMode = mode === 'add'
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={handleClose}>
|
||||
<ModalContent className='max-w-[480px]'>
|
||||
<ModalHeader>{isAddMode ? 'Add New Row' : 'Edit Row'}</ModalHeader>
|
||||
<ModalBody className='max-h-[60vh] space-y-[12px] overflow-y-auto'>
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{columns.map((column) => (
|
||||
<ColumnField
|
||||
key={column.name}
|
||||
column={column}
|
||||
value={rowData[column.name]}
|
||||
onChange={(value) => setRowData((prev) => ({ ...prev, [column.name]: value }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={handleClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={handleFormSubmit}
|
||||
disabled={isSubmitting || !hasRequiredFields}
|
||||
>
|
||||
{isSubmitting
|
||||
? isAddMode
|
||||
? 'Adding...'
|
||||
: 'Updating...'
|
||||
: isAddMode
|
||||
? 'Add Row'
|
||||
: 'Update Row'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorMessage({ error }: { error: string | null }) {
|
||||
if (!error) return null
|
||||
|
||||
return (
|
||||
<div className='rounded-[8px] border border-[var(--status-error-border)] bg-[var(--status-error-bg)] px-[14px] py-[12px] text-[13px] text-[var(--status-error-text)]'>
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ColumnFieldProps {
|
||||
column: ColumnDefinition
|
||||
value: unknown
|
||||
onChange: (value: unknown) => void
|
||||
}
|
||||
|
||||
function ColumnField({ column, value, onChange }: ColumnFieldProps) {
|
||||
const renderInput = () => {
|
||||
if (column.type === 'boolean') {
|
||||
return (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Checkbox
|
||||
id={column.name}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) => onChange(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={column.name}
|
||||
className='font-normal text-[13px] text-[var(--text-tertiary)]'
|
||||
>
|
||||
{value ? 'True' : 'False'}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (column.type === 'json') {
|
||||
return (
|
||||
<Textarea
|
||||
id={column.name}
|
||||
value={formatValueForInput(value, column.type)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={3}
|
||||
className='font-mono text-[12px]'
|
||||
required={column.required}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={column.name}
|
||||
type={column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'}
|
||||
value={formatValueForInput(value, column.type)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={`Enter ${column.name}`}
|
||||
required={column.required}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'>
|
||||
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
{column.name}
|
||||
{column.required && <span className='text-[var(--text-error)]'> *</span>}
|
||||
</span>
|
||||
<Badge size='sm'>{column.type}</Badge>
|
||||
{column.unique && (
|
||||
<Badge size='sm' variant='gray-secondary'>
|
||||
unique
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
<Label className='text-[13px]'>Value</Label>
|
||||
{renderInput()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/emcn'
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
|
||||
interface SchemaModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
columns: ColumnDefinition[]
|
||||
}
|
||||
|
||||
export function SchemaModal({ isOpen, onClose, columns }: SchemaModalProps) {
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={onClose}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Table Schema</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='max-h-[400px] overflow-auto'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-[180px]'>Column</TableHead>
|
||||
<TableHead className='w-[100px]'>Type</TableHead>
|
||||
<TableHead>Constraints</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{columns.map((column) => (
|
||||
<TableRow key={column.name}>
|
||||
<TableCell>{column.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='gray-secondary' size='sm'>
|
||||
{column.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-[6px]'>
|
||||
{column.required && (
|
||||
<Badge variant='gray-secondary' size='sm'>
|
||||
required
|
||||
</Badge>
|
||||
)}
|
||||
{column.unique && (
|
||||
<Badge variant='gray-secondary' size='sm'>
|
||||
unique
|
||||
</Badge>
|
||||
)}
|
||||
{!column.required && !column.unique && (
|
||||
<span className='text-[var(--text-muted)]'>—</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => onClose(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface TableToolbarProps {
|
||||
tableName: string
|
||||
totalCount: number
|
||||
isLoading: boolean
|
||||
onNavigateBack: () => void
|
||||
onShowSchema: () => void
|
||||
onRefresh: () => void
|
||||
showFilters: boolean
|
||||
onToggleFilters: () => void
|
||||
onAddRecord: () => void
|
||||
selectedCount: number
|
||||
onDeleteSelected: () => void
|
||||
onClearSelection: () => void
|
||||
hasPendingChanges: boolean
|
||||
onSaveChanges: () => void
|
||||
onDiscardChanges: () => void
|
||||
isSaving: boolean
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
onPreviousPage: () => void
|
||||
onNextPage: () => void
|
||||
}
|
||||
|
||||
export function TableToolbar({
|
||||
tableName,
|
||||
totalCount,
|
||||
isLoading,
|
||||
onNavigateBack,
|
||||
onShowSchema,
|
||||
onRefresh,
|
||||
showFilters,
|
||||
onToggleFilters,
|
||||
onAddRecord,
|
||||
selectedCount,
|
||||
onDeleteSelected,
|
||||
onClearSelection,
|
||||
hasPendingChanges,
|
||||
onSaveChanges,
|
||||
onDiscardChanges,
|
||||
isSaving,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
}: TableToolbarProps) {
|
||||
const hasSelection = selectedCount > 0
|
||||
|
||||
return (
|
||||
<div className='flex h-[48px] shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-2)] px-[16px]'>
|
||||
{/* Left section: Navigation and table info */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
className='text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
Tables
|
||||
</button>
|
||||
<span className='text-[var(--text-muted)]'>/</span>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>{tableName}</span>
|
||||
</div>
|
||||
|
||||
{/* Center section: Main actions */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{/* Pagination controls */}
|
||||
<div className='flex items-center gap-[2px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={onPreviousPage}
|
||||
disabled={currentPage === 0 || isLoading}
|
||||
>
|
||||
<ChevronLeft className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Previous page</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={onNextPage}
|
||||
disabled={currentPage >= totalPages - 1 || isLoading}
|
||||
>
|
||||
<ChevronRight className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Next page</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
|
||||
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
|
||||
|
||||
{/* Filters toggle */}
|
||||
<Button variant={showFilters ? 'secondary' : 'ghost'} size='sm' onClick={onToggleFilters}>
|
||||
<Filter className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Filters
|
||||
</Button>
|
||||
|
||||
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
|
||||
|
||||
{/* Pending changes actions */}
|
||||
{hasPendingChanges ? (
|
||||
<>
|
||||
<Button variant='tertiary' size='sm' onClick={onSaveChanges} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save changes'}
|
||||
</Button>
|
||||
<Button variant='ghost' size='sm' onClick={onDiscardChanges} disabled={isSaving}>
|
||||
Discard changes
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Add record */}
|
||||
<Button variant='default' size='sm' onClick={onAddRecord}>
|
||||
<Plus className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Add record
|
||||
</Button>
|
||||
|
||||
{/* Delete selected */}
|
||||
{hasSelection && (
|
||||
<Button variant='destructive' size='sm' onClick={onDeleteSelected}>
|
||||
<Trash2 className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Delete {selectedCount} {selectedCount === 1 ? 'record' : 'records'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Clear selection */}
|
||||
{hasSelection && !hasPendingChanges && (
|
||||
<Button variant='ghost' size='sm' onClick={onClearSelection}>
|
||||
Clear selection
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right section: Row count and utilities */}
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
{isLoading ? (
|
||||
<Skeleton className='h-[16px] w-[50px]' />
|
||||
) : (
|
||||
<span className='text-[13px] text-[var(--text-tertiary)]'>
|
||||
{totalCount} {totalCount === 1 ? 'row' : 'rows'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button variant='ghost' size='sm' onClick={onRefresh} disabled={isLoading}>
|
||||
<RefreshCw className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Refresh</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='ghost' size='sm'>
|
||||
<MoreHorizontal className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' className='w-[160px]'>
|
||||
<PopoverItem onClick={onShowSchema}>View Schema</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Checkbox,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useContextMenu, useInlineEditing, useRowSelection, useTableData } from '../hooks'
|
||||
import type { CellViewerData, QueryOptions } from '../lib/types'
|
||||
import { EmptyRows, LoadingRows } from './body-states'
|
||||
import { CellViewerModal } from './cell-viewer-modal'
|
||||
import { ContextMenu } from './context-menu'
|
||||
import { EditableCell } from './editable-cell'
|
||||
import { EditableRow } from './editable-row'
|
||||
import { FilterPanel } from './filter-panel'
|
||||
import { RowModal } from './row-modal'
|
||||
import { SchemaModal } from './schema-modal'
|
||||
import { TableToolbar } from './table-toolbar'
|
||||
|
||||
export function TableViewer() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
|
||||
const workspaceId = params.workspaceId as string
|
||||
const tableId = params.tableId as string
|
||||
|
||||
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
|
||||
filter: null,
|
||||
sort: null,
|
||||
})
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [deletingRows, setDeletingRows] = useState<string[]>([])
|
||||
const [showSchemaModal, setShowSchemaModal] = useState(false)
|
||||
|
||||
const [cellViewer, setCellViewer] = useState<CellViewerData | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows, refetchRows } =
|
||||
useTableData({
|
||||
workspaceId,
|
||||
tableId,
|
||||
queryOptions,
|
||||
currentPage,
|
||||
})
|
||||
|
||||
const columns = tableData?.schema?.columns || []
|
||||
|
||||
const { selectedRows, handleSelectAll, handleSelectRow, clearSelection } = useRowSelection(rows)
|
||||
|
||||
const { contextMenu, handleRowContextMenu, closeContextMenu } = useContextMenu()
|
||||
|
||||
const {
|
||||
newRows,
|
||||
pendingChanges,
|
||||
addNewRow,
|
||||
updateNewRowCell,
|
||||
updateExistingRowCell,
|
||||
saveChanges,
|
||||
discardChanges,
|
||||
hasPendingChanges,
|
||||
isSaving,
|
||||
} = useInlineEditing({
|
||||
workspaceId,
|
||||
tableId,
|
||||
columns,
|
||||
onSuccess: refetchRows,
|
||||
})
|
||||
|
||||
const selectedCount = selectedRows.size
|
||||
const hasSelection = selectedCount > 0
|
||||
const isAllSelected = rows.length > 0 && selectedCount === rows.length
|
||||
|
||||
const handleNavigateBack = useCallback(() => {
|
||||
router.push(`/workspace/${workspaceId}/tables`)
|
||||
}, [router, workspaceId])
|
||||
|
||||
const handleShowSchema = useCallback(() => {
|
||||
setShowSchemaModal(true)
|
||||
}, [])
|
||||
|
||||
const handleToggleFilters = useCallback(() => {
|
||||
setShowFilters((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const handleApplyQueryOptions = useCallback(
|
||||
(options: QueryOptions) => {
|
||||
setQueryOptions(options)
|
||||
setCurrentPage(0)
|
||||
refetchRows()
|
||||
},
|
||||
[refetchRows]
|
||||
)
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
setDeletingRows(Array.from(selectedRows))
|
||||
}, [selectedRows])
|
||||
|
||||
const handleContextMenuEdit = useCallback(() => {
|
||||
// For inline editing, we don't need the modal anymore
|
||||
// The cell becomes editable on click
|
||||
closeContextMenu()
|
||||
}, [closeContextMenu])
|
||||
|
||||
const handleContextMenuDelete = useCallback(() => {
|
||||
if (contextMenu.row) {
|
||||
setDeletingRows([contextMenu.row.id])
|
||||
}
|
||||
closeContextMenu()
|
||||
}, [contextMenu.row, closeContextMenu])
|
||||
|
||||
const handleCopyCellValue = useCallback(async () => {
|
||||
if (cellViewer) {
|
||||
let text: string
|
||||
if (cellViewer.type === 'json') {
|
||||
text = JSON.stringify(cellViewer.value, null, 2)
|
||||
} else if (cellViewer.type === 'date') {
|
||||
text = String(cellViewer.value)
|
||||
} else {
|
||||
text = String(cellViewer.value)
|
||||
}
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}, [cellViewer])
|
||||
|
||||
const handleCellClick = useCallback(
|
||||
(columnName: string, value: unknown, type: CellViewerData['type']) => {
|
||||
setCellViewer({ columnName, value, type })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleRemoveNewRow = useCallback(
|
||||
(tempId: string) => {
|
||||
discardChanges()
|
||||
},
|
||||
[discardChanges]
|
||||
)
|
||||
|
||||
const handlePreviousPage = useCallback(() => {
|
||||
setCurrentPage((p) => Math.max(0, p - 1))
|
||||
}, [])
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
setCurrentPage((p) => Math.min(totalPages - 1, p + 1))
|
||||
}, [totalPages])
|
||||
|
||||
if (isLoadingTable) {
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<span className='text-[13px] text-[var(--text-tertiary)]'>Loading table...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!tableData) {
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<span className='text-[13px] text-[var(--text-error)]'>Table not found</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<TableToolbar
|
||||
tableName={tableData.name}
|
||||
totalCount={totalCount}
|
||||
isLoading={isLoadingRows}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onShowSchema={handleShowSchema}
|
||||
onRefresh={refetchRows}
|
||||
showFilters={showFilters}
|
||||
onToggleFilters={handleToggleFilters}
|
||||
onAddRecord={addNewRow}
|
||||
selectedCount={selectedCount}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
onClearSelection={clearSelection}
|
||||
hasPendingChanges={hasPendingChanges}
|
||||
onSaveChanges={saveChanges}
|
||||
onDiscardChanges={discardChanges}
|
||||
isSaving={isSaving}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPreviousPage={handlePreviousPage}
|
||||
onNextPage={handleNextPage}
|
||||
/>
|
||||
|
||||
<FilterPanel
|
||||
columns={columns}
|
||||
isVisible={showFilters}
|
||||
onApply={handleApplyQueryOptions}
|
||||
onClose={() => setShowFilters(false)}
|
||||
isLoading={isLoadingRows}
|
||||
/>
|
||||
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<Table>
|
||||
<TableHeader className='sticky top-0 z-10 bg-[var(--surface-3)]'>
|
||||
<TableRow>
|
||||
<TableHead className='w-[40px]'>
|
||||
<Checkbox size='sm' checked={isAllSelected} onCheckedChange={handleSelectAll} />
|
||||
</TableHead>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.name}>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<span className='text-[12px]'>{column.name}</span>
|
||||
<Badge variant='outline' size='sm'>
|
||||
{column.type}
|
||||
</Badge>
|
||||
{column.required && (
|
||||
<span className='text-[10px] text-[var(--text-error)]'>*</span>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* New rows being added */}
|
||||
{newRows.map((newRow) => (
|
||||
<EditableRow
|
||||
key={newRow.tempId}
|
||||
row={newRow}
|
||||
columns={columns}
|
||||
onUpdateCell={updateNewRowCell}
|
||||
onRemove={handleRemoveNewRow}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoadingRows ? (
|
||||
<LoadingRows columns={columns} />
|
||||
) : rows.length === 0 && newRows.length === 0 ? (
|
||||
<EmptyRows
|
||||
columnCount={columns.length}
|
||||
hasFilter={!!queryOptions.filter}
|
||||
onAddRow={addNewRow}
|
||||
/>
|
||||
) : (
|
||||
/* Existing rows with inline editing */
|
||||
rows.map((row) => {
|
||||
const rowChanges = pendingChanges.get(row.id)
|
||||
const hasChanges = !!rowChanges
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'group hover:bg-[var(--surface-4)]',
|
||||
selectedRows.has(row.id) && 'bg-[var(--surface-5)]',
|
||||
hasChanges && 'bg-amber-500/10'
|
||||
)}
|
||||
onContextMenu={(e) => handleRowContextMenu(e, row)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={selectedRows.has(row.id)}
|
||||
onCheckedChange={() => handleSelectRow(row.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
{columns.map((column) => {
|
||||
const currentValue = rowChanges?.[column.name] ?? row.data[column.name]
|
||||
|
||||
return (
|
||||
<TableCell key={column.name}>
|
||||
<EditableCell
|
||||
value={currentValue}
|
||||
column={column}
|
||||
onChange={(value) => updateExistingRowCell(row.id, column.name, value)}
|
||||
/>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
{deletingRows.length > 0 && (
|
||||
<RowModal
|
||||
mode='delete'
|
||||
isOpen={true}
|
||||
onClose={() => setDeletingRows([])}
|
||||
table={tableData}
|
||||
rowIds={deletingRows}
|
||||
onSuccess={() => {
|
||||
refetchRows()
|
||||
setDeletingRows([])
|
||||
clearSelection()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SchemaModal
|
||||
isOpen={showSchemaModal}
|
||||
onClose={() => setShowSchemaModal(false)}
|
||||
columns={columns}
|
||||
/>
|
||||
|
||||
<CellViewerModal
|
||||
cellViewer={cellViewer}
|
||||
onClose={() => setCellViewer(null)}
|
||||
onCopy={handleCopyCellValue}
|
||||
copied={copied}
|
||||
/>
|
||||
|
||||
<ContextMenu
|
||||
contextMenu={contextMenu}
|
||||
onClose={closeContextMenu}
|
||||
onEdit={handleContextMenuEdit}
|
||||
onDelete={handleContextMenuDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './use-context-menu'
|
||||
export * from './use-inline-editing'
|
||||
export * from './use-row-selection'
|
||||
export * from './use-table-data'
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { TableRow } from '@/lib/table'
|
||||
import type { ContextMenuState } from '../lib/types'
|
||||
|
||||
interface UseContextMenuReturn {
|
||||
contextMenu: ContextMenuState
|
||||
handleRowContextMenu: (e: React.MouseEvent, row: TableRow) => void
|
||||
closeContextMenu: () => void
|
||||
}
|
||||
|
||||
export function useContextMenu(): UseContextMenuReturn {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
isOpen: false,
|
||||
position: { x: 0, y: 0 },
|
||||
row: null,
|
||||
})
|
||||
|
||||
const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRow) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenu({
|
||||
isOpen: true,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
row,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenu((prev) => ({ ...prev, isOpen: false }))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
contextMenu,
|
||||
handleRowContextMenu,
|
||||
closeContextMenu,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
|
||||
const logger = createLogger('useInlineEditing')
|
||||
|
||||
export interface TempRow {
|
||||
tempId: string
|
||||
data: Record<string, unknown>
|
||||
isNew: true
|
||||
}
|
||||
|
||||
interface UseInlineEditingProps {
|
||||
workspaceId: string
|
||||
tableId: string
|
||||
columns: ColumnDefinition[]
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
interface UseInlineEditingReturn {
|
||||
newRows: TempRow[]
|
||||
pendingChanges: Map<string, Record<string, unknown>>
|
||||
addNewRow: () => void
|
||||
updateNewRowCell: (tempId: string, column: string, value: unknown) => void
|
||||
updateExistingRowCell: (rowId: string, column: string, value: unknown) => void
|
||||
saveChanges: () => Promise<void>
|
||||
discardChanges: () => void
|
||||
hasPendingChanges: boolean
|
||||
isSaving: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function createInitialRowData(columns: ColumnDefinition[]): Record<string, unknown> {
|
||||
const initial: Record<string, unknown> = {}
|
||||
columns.forEach((col) => {
|
||||
if (col.type === 'boolean') {
|
||||
initial[col.name] = false
|
||||
} else {
|
||||
initial[col.name] = null
|
||||
}
|
||||
})
|
||||
return initial
|
||||
}
|
||||
|
||||
function cleanRowData(
|
||||
columns: ColumnDefinition[],
|
||||
rowData: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const cleanData: Record<string, unknown> = {}
|
||||
|
||||
columns.forEach((col) => {
|
||||
const value = rowData[col.name]
|
||||
if (col.type === 'number') {
|
||||
cleanData[col.name] = value === '' || value === null ? null : Number(value)
|
||||
} else if (col.type === 'json') {
|
||||
if (typeof value === 'string') {
|
||||
if (value === '') {
|
||||
cleanData[col.name] = null
|
||||
} else {
|
||||
try {
|
||||
cleanData[col.name] = JSON.parse(value)
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON for field: ${col.name}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cleanData[col.name] = value
|
||||
}
|
||||
} else if (col.type === 'boolean') {
|
||||
cleanData[col.name] = Boolean(value)
|
||||
} else {
|
||||
cleanData[col.name] = value || null
|
||||
}
|
||||
})
|
||||
|
||||
return cleanData
|
||||
}
|
||||
|
||||
export function useInlineEditing({
|
||||
workspaceId,
|
||||
tableId,
|
||||
columns,
|
||||
onSuccess,
|
||||
}: UseInlineEditingProps): UseInlineEditingReturn {
|
||||
const [newRows, setNewRows] = useState<TempRow[]>([])
|
||||
const [pendingChanges, setPendingChanges] = useState<Map<string, Record<string, unknown>>>(
|
||||
new Map()
|
||||
)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const hasPendingChanges = newRows.length > 0 || pendingChanges.size > 0
|
||||
|
||||
const addNewRow = useCallback(() => {
|
||||
const newRow: TempRow = {
|
||||
tempId: `temp-${nanoid()}`,
|
||||
data: createInitialRowData(columns),
|
||||
isNew: true,
|
||||
}
|
||||
setNewRows((prev) => [newRow, ...prev])
|
||||
}, [columns])
|
||||
|
||||
const updateNewRowCell = useCallback((tempId: string, column: string, value: unknown) => {
|
||||
setNewRows((prev) =>
|
||||
prev.map((row) =>
|
||||
row.tempId === tempId ? { ...row, data: { ...row.data, [column]: value } } : row
|
||||
)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const updateExistingRowCell = useCallback((rowId: string, column: string, value: unknown) => {
|
||||
setPendingChanges((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
const existing = newMap.get(rowId) || {}
|
||||
newMap.set(rowId, { ...existing, [column]: value })
|
||||
return newMap
|
||||
})
|
||||
}, [])
|
||||
|
||||
const saveChanges = useCallback(async () => {
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Save new rows
|
||||
for (const newRow of newRows) {
|
||||
const cleanData = cleanRowData(columns, newRow.data)
|
||||
|
||||
const res = await fetch(`/api/table/${tableId}/rows`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId, data: cleanData }),
|
||||
})
|
||||
|
||||
const result: { error?: string } = await res.json()
|
||||
if (!res.ok) {
|
||||
throw new Error(result.error || 'Failed to add row')
|
||||
}
|
||||
}
|
||||
|
||||
// Save edited rows
|
||||
for (const [rowId, changes] of pendingChanges.entries()) {
|
||||
const cleanData = cleanRowData(columns, changes)
|
||||
|
||||
const res = await fetch(`/api/table/${tableId}/rows/${rowId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId, data: cleanData }),
|
||||
})
|
||||
|
||||
const result: { error?: string } = await res.json()
|
||||
if (!res.ok) {
|
||||
throw new Error(result.error || 'Failed to update row')
|
||||
}
|
||||
}
|
||||
|
||||
// Clear state and refresh
|
||||
setNewRows([])
|
||||
setPendingChanges(new Map())
|
||||
onSuccess()
|
||||
|
||||
logger.info('Changes saved successfully')
|
||||
} catch (err) {
|
||||
logger.error('Failed to save changes:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to save changes')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [newRows, pendingChanges, columns, tableId, workspaceId, onSuccess])
|
||||
|
||||
const discardChanges = useCallback(() => {
|
||||
setNewRows([])
|
||||
setPendingChanges(new Map())
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
newRows,
|
||||
pendingChanges,
|
||||
addNewRow,
|
||||
updateNewRowCell,
|
||||
updateExistingRowCell,
|
||||
saveChanges,
|
||||
discardChanges,
|
||||
hasPendingChanges,
|
||||
isSaving,
|
||||
error,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { TableRow } from '@/lib/table'
|
||||
|
||||
interface UseRowSelectionReturn {
|
||||
selectedRows: Set<string>
|
||||
handleSelectAll: () => void
|
||||
handleSelectRow: (rowId: string) => void
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
export function useRowSelection(rows: TableRow[]): UseRowSelectionReturn {
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRows((prev) => {
|
||||
if (prev.size === 0) return prev
|
||||
|
||||
const currentRowIds = new Set(rows.map((r) => r.id))
|
||||
const filtered = new Set([...prev].filter((id) => currentRowIds.has(id)))
|
||||
|
||||
// Only update state if something was actually filtered out
|
||||
return filtered.size !== prev.size ? filtered : prev
|
||||
})
|
||||
}, [rows])
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (selectedRows.size === rows.length) {
|
||||
setSelectedRows(new Set())
|
||||
} else {
|
||||
setSelectedRows(new Set(rows.map((r) => r.id)))
|
||||
}
|
||||
}, [rows, selectedRows.size])
|
||||
|
||||
const handleSelectRow = useCallback((rowId: string) => {
|
||||
setSelectedRows((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(rowId)) {
|
||||
newSet.delete(rowId)
|
||||
} else {
|
||||
newSet.add(rowId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedRows(new Set())
|
||||
}, [])
|
||||
|
||||
return {
|
||||
selectedRows,
|
||||
handleSelectAll,
|
||||
handleSelectRow,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { TableDefinition, TableRow } from '@/lib/table'
|
||||
import { ROWS_PER_PAGE } from '../lib/constants'
|
||||
import type { QueryOptions } from '../lib/types'
|
||||
|
||||
interface UseTableDataParams {
|
||||
workspaceId: string
|
||||
tableId: string
|
||||
queryOptions: QueryOptions
|
||||
currentPage: number
|
||||
}
|
||||
|
||||
interface UseTableDataReturn {
|
||||
tableData: TableDefinition | undefined
|
||||
isLoadingTable: boolean
|
||||
rows: TableRow[]
|
||||
totalCount: number
|
||||
totalPages: number
|
||||
isLoadingRows: boolean
|
||||
refetchRows: () => void
|
||||
}
|
||||
|
||||
export function useTableData({
|
||||
workspaceId,
|
||||
tableId,
|
||||
queryOptions,
|
||||
currentPage,
|
||||
}: UseTableDataParams): UseTableDataReturn {
|
||||
const { data: tableData, isLoading: isLoadingTable } = useQuery({
|
||||
queryKey: ['table', tableId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/table/${tableId}?workspaceId=${workspaceId}`)
|
||||
if (!res.ok) throw new Error('Failed to fetch table')
|
||||
const json: { data?: { table: TableDefinition }; table?: TableDefinition } = await res.json()
|
||||
const data = json.data || json
|
||||
return (data as { table: TableDefinition }).table
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
data: rowsData,
|
||||
isLoading: isLoadingRows,
|
||||
refetch: refetchRows,
|
||||
} = useQuery({
|
||||
queryKey: ['table-rows', tableId, queryOptions, currentPage],
|
||||
queryFn: async () => {
|
||||
const searchParams = new URLSearchParams({
|
||||
workspaceId,
|
||||
limit: String(ROWS_PER_PAGE),
|
||||
offset: String(currentPage * ROWS_PER_PAGE),
|
||||
})
|
||||
|
||||
if (queryOptions.filter) {
|
||||
searchParams.set('filter', JSON.stringify(queryOptions.filter))
|
||||
}
|
||||
|
||||
if (queryOptions.sort) {
|
||||
searchParams.set('sort', JSON.stringify(queryOptions.sort))
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/table/${tableId}/rows?${searchParams}`)
|
||||
if (!res.ok) throw new Error('Failed to fetch rows')
|
||||
const json: {
|
||||
data?: { rows: TableRow[]; totalCount: number }
|
||||
rows?: TableRow[]
|
||||
totalCount?: number
|
||||
} = await res.json()
|
||||
return json.data || json
|
||||
},
|
||||
enabled: !!tableData,
|
||||
})
|
||||
|
||||
const rows = (rowsData?.rows || []) as TableRow[]
|
||||
const totalCount = rowsData?.totalCount || 0
|
||||
const totalPages = Math.ceil(totalCount / ROWS_PER_PAGE)
|
||||
|
||||
return {
|
||||
tableData,
|
||||
isLoadingTable,
|
||||
rows,
|
||||
totalCount,
|
||||
totalPages,
|
||||
isLoadingRows,
|
||||
refetchRows,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ROWS_PER_PAGE = 100
|
||||
export const STRING_TRUNCATE_LENGTH = 50
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './constants'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Filter, Sort, TableRow } from '@/lib/table'
|
||||
|
||||
/**
|
||||
* Query options for filtering and sorting table data
|
||||
*/
|
||||
export interface QueryOptions {
|
||||
filter: Filter | null
|
||||
sort: Sort | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Data for viewing a cell's full content in a modal
|
||||
*/
|
||||
export interface CellViewerData {
|
||||
columnName: string
|
||||
value: unknown
|
||||
type: 'json' | 'text' | 'date' | 'boolean' | 'number'
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the row context menu (right-click)
|
||||
*/
|
||||
export interface ContextMenuState {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
row: TableRow | null
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
type BadgeVariant = 'green' | 'blue' | 'purple' | 'orange' | 'teal' | 'gray'
|
||||
|
||||
/**
|
||||
* Returns the appropriate badge color variant for a column type
|
||||
*/
|
||||
export function getTypeBadgeVariant(type: string): BadgeVariant {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return 'green'
|
||||
case 'number':
|
||||
return 'blue'
|
||||
case 'boolean':
|
||||
return 'purple'
|
||||
case 'json':
|
||||
return 'orange'
|
||||
case 'date':
|
||||
return 'teal'
|
||||
default:
|
||||
return 'gray'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TableViewer } from './components'
|
||||
|
||||
export default function TablePage() {
|
||||
return <TableViewer />
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type SortOption = 'name' | 'createdAt' | 'updatedAt' | 'rowCount' | 'columnCount'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: 'updatedAt-desc', label: 'Last Updated' },
|
||||
{ value: 'createdAt-desc', label: 'Newest First' },
|
||||
{ value: 'createdAt-asc', label: 'Oldest First' },
|
||||
{ value: 'name-asc', label: 'Name (A-Z)' },
|
||||
{ value: 'name-desc', label: 'Name (Z-A)' },
|
||||
{ value: 'rowCount-desc', label: 'Most Rows' },
|
||||
{ value: 'rowCount-asc', label: 'Least Rows' },
|
||||
{ value: 'columnCount-desc', label: 'Most Columns' },
|
||||
{ value: 'columnCount-asc', label: 'Least Columns' },
|
||||
] as const
|
||||
@@ -0,0 +1,349 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
import { useCreateTable } from '@/hooks/queries/use-tables'
|
||||
|
||||
const logger = createLogger('CreateModal')
|
||||
|
||||
interface CreateModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const COLUMN_TYPE_OPTIONS: Array<{ value: ColumnDefinition['type']; label: string }> = [
|
||||
{ value: 'string', label: 'String' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'boolean', label: 'Boolean' },
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
]
|
||||
|
||||
interface ColumnWithId extends ColumnDefinition {
|
||||
id: string
|
||||
}
|
||||
|
||||
function createEmptyColumn(): ColumnWithId {
|
||||
return { id: nanoid(), name: '', type: 'string', required: true, unique: false }
|
||||
}
|
||||
|
||||
export function CreateModal({ isOpen, onClose }: CreateModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const [tableName, setTableName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [columns, setColumns] = useState<ColumnWithId[]>([createEmptyColumn()])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const createTable = useCreateTable(workspaceId)
|
||||
|
||||
// Form validation
|
||||
const validColumns = useMemo(() => columns.filter((col) => col.name.trim()), [columns])
|
||||
const duplicateColumnNames = useMemo(() => {
|
||||
const names = validColumns.map((col) => col.name.toLowerCase())
|
||||
const seen = new Set<string>()
|
||||
const duplicates = new Set<string>()
|
||||
names.forEach((name) => {
|
||||
if (seen.has(name)) {
|
||||
duplicates.add(name)
|
||||
}
|
||||
seen.add(name)
|
||||
})
|
||||
return duplicates
|
||||
}, [validColumns])
|
||||
|
||||
const isFormValid = useMemo(() => {
|
||||
const hasTableName = tableName.trim().length > 0
|
||||
const hasAtLeastOneColumn = validColumns.length > 0
|
||||
const hasNoDuplicates = duplicateColumnNames.size === 0
|
||||
return hasTableName && hasAtLeastOneColumn && hasNoDuplicates
|
||||
}, [tableName, validColumns.length, duplicateColumnNames.size])
|
||||
|
||||
const handleAddColumn = () => {
|
||||
setColumns([...columns, createEmptyColumn()])
|
||||
}
|
||||
|
||||
const handleRemoveColumn = (columnId: string) => {
|
||||
if (columns.length > 1) {
|
||||
setColumns(columns.filter((col) => col.id !== columnId))
|
||||
}
|
||||
}
|
||||
|
||||
const handleColumnChange = (
|
||||
columnId: string,
|
||||
field: keyof ColumnDefinition,
|
||||
value: string | boolean
|
||||
) => {
|
||||
setColumns(columns.map((col) => (col.id === columnId ? { ...col, [field]: value } : col)))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (!tableName.trim()) {
|
||||
setError('Table name is required')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate column names
|
||||
const validColumns = columns.filter((col) => col.name.trim())
|
||||
if (validColumns.length === 0) {
|
||||
setError('At least one column is required')
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicate column names
|
||||
const columnNames = validColumns.map((col) => col.name.toLowerCase())
|
||||
const uniqueNames = new Set(columnNames)
|
||||
if (uniqueNames.size !== columnNames.length) {
|
||||
setError('Duplicate column names found')
|
||||
return
|
||||
}
|
||||
|
||||
// Strip internal IDs before sending to API
|
||||
const columnsForApi = validColumns.map(({ id: _id, ...col }) => col)
|
||||
|
||||
try {
|
||||
await createTable.mutateAsync({
|
||||
name: tableName,
|
||||
description: description || undefined,
|
||||
schema: {
|
||||
columns: columnsForApi,
|
||||
},
|
||||
})
|
||||
|
||||
// Reset form
|
||||
resetForm()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
logger.error('Failed to create table:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to create table')
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setTableName('')
|
||||
setDescription('')
|
||||
setColumns([createEmptyColumn()])
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={handleClose}>
|
||||
<ModalContent size='lg'>
|
||||
<ModalHeader>Create New Table</ModalHeader>
|
||||
<ModalBody className='max-h-[70vh] overflow-y-auto'>
|
||||
<form onSubmit={handleSubmit} className='space-y-[12px]'>
|
||||
{error && (
|
||||
<div className='rounded-[4px] border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table Name */}
|
||||
<div>
|
||||
<Label
|
||||
htmlFor='tableName'
|
||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||
>
|
||||
Table Name
|
||||
</Label>
|
||||
<Input
|
||||
id='tableName'
|
||||
value={tableName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTableName(e.target.value)}
|
||||
placeholder='e.g., customer_orders'
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<Label
|
||||
htmlFor='description'
|
||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||
>
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id='description'
|
||||
value={description}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setDescription(e.target.value)
|
||||
}
|
||||
placeholder='Optional description for this table'
|
||||
rows={2}
|
||||
className='resize-none'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Columns */}
|
||||
<div>
|
||||
<div className='mb-[6.5px] flex items-center justify-between pl-[2px]'>
|
||||
<Label className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Columns
|
||||
</Label>
|
||||
<Button type='button' size='sm' variant='default' onClick={handleAddColumn}>
|
||||
<Plus className='mr-1 h-3.5 w-3.5' />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Column Headers */}
|
||||
<div className='mb-2 flex items-center gap-[10px] text-[11px] text-[var(--text-secondary)]'>
|
||||
<div className='flex-1 pl-3'>Name</div>
|
||||
<div className='w-[110px] pl-3'>Type</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-[70px] cursor-help text-center'>Required</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Field must have a value</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='w-[70px] cursor-help text-center'>Unique</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>No duplicate values allowed</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<div className='w-9' />
|
||||
</div>
|
||||
|
||||
{/* Column Rows */}
|
||||
<div className='flex flex-col gap-2'>
|
||||
{columns.map((column) => (
|
||||
<ColumnRow
|
||||
key={column.id}
|
||||
column={column}
|
||||
isRemovable={columns.length > 1}
|
||||
isDuplicate={duplicateColumnNames.has(column.name.toLowerCase())}
|
||||
onChange={handleColumnChange}
|
||||
onRemove={handleRemoveColumn}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
|
||||
Mark columns as unique to prevent duplicate values (e.g., id, email)
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={handleClose} disabled={createTable.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={handleSubmit}
|
||||
disabled={createTable.isPending || !isFormValid}
|
||||
>
|
||||
{createTable.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
interface ColumnRowProps {
|
||||
column: ColumnWithId
|
||||
isRemovable: boolean
|
||||
isDuplicate: boolean
|
||||
onChange: (columnId: string, field: keyof ColumnDefinition, value: string | boolean) => void
|
||||
onRemove: (columnId: string) => void
|
||||
}
|
||||
|
||||
function ColumnRow({ column, isRemovable, isDuplicate, onChange, onRemove }: ColumnRowProps) {
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-[10px]'>
|
||||
{/* Column Name */}
|
||||
<div className='flex-1'>
|
||||
<Input
|
||||
value={column.name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(column.id, 'name', e.target.value)
|
||||
}
|
||||
placeholder='column_name'
|
||||
className={`h-9 ${isDuplicate ? 'border-destructive focus-visible:ring-destructive' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Column Type */}
|
||||
<div className='w-[110px]'>
|
||||
<Combobox
|
||||
options={COLUMN_TYPE_OPTIONS}
|
||||
value={column.type}
|
||||
selectedValue={column.type}
|
||||
onChange={(value) => onChange(column.id, 'type', value as ColumnDefinition['type'])}
|
||||
placeholder='Type'
|
||||
editable={false}
|
||||
filterOptions={false}
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Required Checkbox */}
|
||||
<div className='flex w-[70px] items-center justify-center'>
|
||||
<Checkbox
|
||||
checked={column.required}
|
||||
onCheckedChange={(checked) => onChange(column.id, 'required', checked === true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Unique Checkbox */}
|
||||
<div className='flex w-[70px] items-center justify-center'>
|
||||
<Checkbox
|
||||
checked={column.unique}
|
||||
onCheckedChange={(checked) => onChange(column.id, 'unique', checked === true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
<div className='w-9'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => onRemove(column.id)}
|
||||
disabled={!isRemovable}
|
||||
className='h-9 w-9 p-0'
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Remove column</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
{isDuplicate && <p className='mt-1 pl-1 text-destructive text-sm'>Duplicate column name</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user