Compare commits

..

1 Commits

Author SHA1 Message Date
waleed
c6821bf547 feat(generic): enable results tab wiring 2026-03-27 17:26:13 -07:00
288 changed files with 5701 additions and 44567 deletions

View File

@@ -74,10 +74,6 @@ docker compose -f docker-compose.prod.yml up -d
Open [http://localhost:3000](http://localhost:3000)
#### Background worker note
The Docker Compose stack starts a dedicated worker container by default. If `REDIS_URL` is not configured, the worker will start, log that it is idle, and do no queue processing. This is expected. Queue-backed API, webhook, and schedule execution requires Redis; installs without Redis continue to use the inline execution path.
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.
### Self-hosted: Manual Setup
@@ -117,12 +113,10 @@ cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
5. Start development servers:
```bash
bun run dev:full # Starts Next.js app, realtime socket server, and the BullMQ worker
bun run dev:full # Starts both Next.js app and realtime socket server
```
If `REDIS_URL` is not configured, the worker will remain idle and execution continues inline.
Or run separately: `bun run dev` (Next.js), `cd apps/sim && bun run dev:sockets` (realtime), and `cd apps/sim && bun run worker` (BullMQ worker).
Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime).
## Copilot API Keys

View File

@@ -18,7 +18,7 @@ export const metadata = {
metadataBase: new URL('https://docs.sim.ai'),
title: {
default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
template: '%s | Sim Docs',
template: '%s',
},
description:
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',

View File

@@ -1285,17 +1285,6 @@ export function StartIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function ProfoundIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg width='1em' height='1em' viewBox='0 0 55 55' xmlns='http://www.w3.org/2000/svg' {...props}>
<path
fill='currentColor'
d='M0 36.685V21.349a7.017 7.017 0 0 1 2.906-5.69l19.742-14.25A7.443 7.443 0 0 1 27.004 0h.062c1.623 0 3.193.508 4.501 1.452l19.684 14.207a7.016 7.016 0 0 1 2.906 5.69v12.302a7.013 7.013 0 0 1-2.907 5.689L31.527 53.562A7.605 7.605 0 0 1 27.078 55a7.641 7.641 0 0 1-4.465-1.44c-2.581-1.859-6.732-4.855-6.732-4.855V29.777c0-.249.28-.393.482-.248l10.538 7.605c.106.077.249.077.355 0l13.005-9.386a.306.306 0 0 0 0-.496l-13.005-9.386a.303.303 0 0 0-.355 0L.482 36.933A.304.304 0 0 1 0 36.685Z'
/>
</svg>
)
}
export function PineconeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -126,7 +126,6 @@ import {
PolymarketIcon,
PostgresIcon,
PosthogIcon,
ProfoundIcon,
PulseIcon,
QdrantIcon,
QuiverIcon,
@@ -303,7 +302,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
posthog: PosthogIcon,
profound: ProfoundIcon,
pulse_v2: PulseIcon,
qdrant: QdrantIcon,
quiver: QuiverIcon,

View File

@@ -1,6 +1,6 @@
---
title: File
description: Read and write workspace files
description: Read and parse multiple files
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -27,7 +27,7 @@ The File Parser tool is particularly useful for scenarios where your agents need
## Usage Instructions
Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.
Upload files directly or import from external URLs to get UserFile objects for use in other blocks.
@@ -52,45 +52,4 @@ Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc
| `files` | file[] | Parsed files as UserFile objects |
| `combinedContent` | string | Combined content of all parsed files |
### `file_write`
Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g.,
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileName` | string | Yes | File name \(e.g., "data.csv"\). If a file with this name exists, a numeric suffix is added automatically. |
| `content` | string | Yes | The text content to write to the file. |
| `contentType` | string | No | MIME type for new files \(e.g., "text/plain"\). Auto-detected from file extension if omitted. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `size` | number | File size in bytes |
| `url` | string | URL to access the file |
### `file_append`
Append content to an existing workspace file. The file must already exist. Content is added to the end of the file.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileName` | string | Yes | Name of an existing workspace file to append to. |
| `content` | string | Yes | The text content to append to the file. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | File ID |
| `name` | string | File name |
| `size` | number | File size in bytes |
| `url` | string | URL to access the file |

View File

@@ -121,7 +121,6 @@
"polymarket",
"postgresql",
"posthog",
"profound",
"pulse",
"qdrant",
"quiver",

View File

@@ -1,626 +0,0 @@
---
title: Profound
description: AI visibility and analytics with Profound
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="profound"
color="#000000"
/>
{/* MANUAL-CONTENT-START:intro */}
[Profound](https://tryprofound.com/) is an AI visibility and analytics platform that helps brands understand how they appear across AI-powered search engines, chatbots, and assistants. It tracks mentions, citations, sentiment, bot traffic, and referral patterns across platforms like ChatGPT, Perplexity, Google AI Overviews, and more.
With the Profound integration in Sim, you can:
- **Monitor AI Visibility**: Track share of voice, visibility scores, and mention counts across AI platforms for your brand and competitors.
- **Analyze Sentiment**: Measure how positively or negatively your brand is discussed in AI-generated responses.
- **Track Citations**: See which URLs are being cited by AI models and your citation share relative to competitors.
- **Monitor Bot Traffic**: Analyze AI crawler activity on your domain, including GPTBot, ClaudeBot, and other AI agents, with hourly granularity.
- **Track Referral Traffic**: Monitor human visits arriving from AI platforms to your website.
- **Explore Prompt Data**: Access raw prompt-answer pairs, query fanouts, and prompt volume trends across AI platforms.
- **Optimize Content**: Get AEO (Answer Engine Optimization) scores and actionable recommendations to improve how AI models reference your content.
- **Manage Categories & Assets**: List and explore your tracked categories, assets (brands), topics, tags, personas, and regions.
These tools let your agents automate AI visibility monitoring, competitive intelligence, and content optimization workflows. To use the Profound integration, you'll need a Profound account with API access.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Track how your brand appears across AI platforms. Monitor visibility scores, sentiment, citations, bot traffic, referrals, content optimization, and prompt volumes with Profound.
## Tools
### `profound_list_categories`
List all organization categories in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `categories` | json | List of organization categories |
| ↳ `id` | string | Category ID |
| ↳ `name` | string | Category name |
### `profound_list_regions`
List all organization regions in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `regions` | json | List of organization regions |
| ↳ `id` | string | Region ID \(UUID\) |
| ↳ `name` | string | Region name |
### `profound_list_models`
List all AI models/platforms tracked in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `models` | json | List of AI models/platforms |
| ↳ `id` | string | Model ID \(UUID\) |
| ↳ `name` | string | Model/platform name |
### `profound_list_domains`
List all organization domains in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `domains` | json | List of organization domains |
| ↳ `id` | string | Domain ID \(UUID\) |
| ↳ `name` | string | Domain name |
| ↳ `createdAt` | string | When the domain was added |
### `profound_list_assets`
List all organization assets (companies/brands) across all categories in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `assets` | json | List of organization assets with category info |
| ↳ `id` | string | Asset ID |
| ↳ `name` | string | Asset/company name |
| ↳ `website` | string | Asset website URL |
| ↳ `alternateDomains` | json | Alternate domain names |
| ↳ `isOwned` | boolean | Whether this asset is owned by the organization |
| ↳ `createdAt` | string | When the asset was created |
| ↳ `logoUrl` | string | URL of the asset logo |
| ↳ `categoryId` | string | Category ID the asset belongs to |
| ↳ `categoryName` | string | Category name |
### `profound_list_personas`
List all organization personas across all categories in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `personas` | json | List of organization personas with profile details |
| ↳ `id` | string | Persona ID |
| ↳ `name` | string | Persona name |
| ↳ `categoryId` | string | Category ID |
| ↳ `categoryName` | string | Category name |
| ↳ `persona` | json | Persona profile with behavior, employment, and demographics |
### `profound_category_topics`
List topics for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `topics` | json | List of topics in the category |
| ↳ `id` | string | Topic ID \(UUID\) |
| ↳ `name` | string | Topic name |
### `profound_category_tags`
List tags for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tags` | json | List of tags in the category |
| ↳ `id` | string | Tag ID \(UUID\) |
| ↳ `name` | string | Tag name |
### `profound_category_prompts`
List prompts for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `limit` | number | No | Maximum number of results \(default 10000, max 10000\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `orderDir` | string | No | Sort direction: asc or desc \(default desc\) |
| `promptType` | string | No | Comma-separated prompt types to filter: visibility, sentiment |
| `topicId` | string | No | Comma-separated topic IDs \(UUIDs\) to filter by |
| `tagId` | string | No | Comma-separated tag IDs \(UUIDs\) to filter by |
| `regionId` | string | No | Comma-separated region IDs \(UUIDs\) to filter by |
| `platformId` | string | No | Comma-separated platform IDs \(UUIDs\) to filter by |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of prompts |
| `nextCursor` | string | Cursor for next page of results |
| `prompts` | json | List of prompts |
| ↳ `id` | string | Prompt ID |
| ↳ `prompt` | string | Prompt text |
| ↳ `promptType` | string | Prompt type \(visibility or sentiment\) |
| ↳ `topicId` | string | Topic ID |
| ↳ `topicName` | string | Topic name |
| ↳ `tags` | json | Associated tags |
| ↳ `regions` | json | Associated regions |
| ↳ `platforms` | json | Associated platforms |
| ↳ `createdAt` | string | When the prompt was created |
### `profound_category_assets`
List assets (companies/brands) for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `assets` | json | List of assets in the category |
| ↳ `id` | string | Asset ID |
| ↳ `name` | string | Asset/company name |
| ↳ `website` | string | Website URL |
| ↳ `alternateDomains` | json | Alternate domain names |
| ↳ `isOwned` | boolean | Whether the asset is owned by the organization |
| ↳ `createdAt` | string | When the asset was created |
| ↳ `logoUrl` | string | URL of the asset logo |
### `profound_category_personas`
List personas for a specific category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `personas` | json | List of personas in the category |
| ↳ `id` | string | Persona ID |
| ↳ `name` | string | Persona name |
| ↳ `persona` | json | Persona profile with behavior, employment, and demographics |
### `profound_visibility_report`
Query AI visibility report for a category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: share_of_voice, mentions_count, visibility_score, executions, average_position |
| `dimensions` | string | No | Comma-separated dimensions: date, region, topic, model, asset_name, prompt, tag, persona |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"asset_name","operator":"is","value":"Company"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_sentiment_report`
Query sentiment report for a category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: positive, negative, occurrences |
| `dimensions` | string | No | Comma-separated dimensions: theme, date, region, topic, model, asset_name, tag, prompt, sentiment_type, persona |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"asset_name","operator":"is","value":"Company"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_citations_report`
Query citations report for a category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: count, citation_share |
| `dimensions` | string | No | Comma-separated dimensions: hostname, path, date, region, topic, model, tag, prompt, url, root_domain, persona, citation_category |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"hostname","operator":"is","value":"example.com"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_query_fanouts`
Query fanout report showing how AI models expand prompts into sub-queries in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: fanouts_per_execution, total_fanouts, share |
| `dimensions` | string | No | Comma-separated dimensions: prompt, query, model, region, date |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_prompt_answers`
Get raw prompt answers data for a category in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `categoryId` | string | Yes | Category ID \(UUID\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"prompt_type","operator":"is","value":"visibility"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of answer rows |
| `data` | json | Raw prompt answer data |
| ↳ `prompt` | string | The prompt text |
| ↳ `promptType` | string | Prompt type \(visibility or sentiment\) |
| ↳ `response` | string | AI model response text |
| ↳ `mentions` | json | Companies/assets mentioned in the response |
| ↳ `citations` | json | URLs cited in the response |
| ↳ `topic` | string | Topic name |
| ↳ `region` | string | Region name |
| ↳ `model` | string | AI model/platform name |
| ↳ `asset` | string | Asset name |
| ↳ `createdAt` | string | Timestamp when the answer was collected |
### `profound_bots_report`
Query bot traffic report with hourly granularity for a domain in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `domain` | string | Yes | Domain to query bot traffic for \(e.g. example.com\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
| `metrics` | string | Yes | Comma-separated metrics: count, citations, indexing, training, last_visit |
| `dimensions` | string | No | Comma-separated dimensions: date, hour, path, bot_name, bot_provider, bot_type |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"bot_name","operator":"is","value":"GPTBot"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_referrals_report`
Query human referral traffic report with hourly granularity for a domain in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `domain` | string | Yes | Domain to query referral traffic for \(e.g. example.com\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
| `metrics` | string | Yes | Comma-separated metrics: visits, last_visit |
| `dimensions` | string | No | Comma-separated dimensions: date, hour, path, referral_source, referral_type |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"referral_source","operator":"is","value":"openai"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Report data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_raw_logs`
Get raw traffic logs with filters for a domain in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `domain` | string | Yes | Domain to query logs for \(e.g. example.com\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
| `dimensions` | string | No | Comma-separated dimensions: timestamp, method, host, path, status_code, ip, user_agent, referer, bytes_sent, duration_ms, query_params |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"path","operator":"contains","value":"/blog"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of log entries |
| `data` | json | Log data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values \(count\) |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_bot_logs`
Get identified bot visit logs with filters for a domain in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `domain` | string | Yes | Domain to query bot logs for \(e.g. example.com\) |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | No | End date \(YYYY-MM-DD or ISO 8601\). Defaults to now |
| `dimensions` | string | No | Comma-separated dimensions: timestamp, method, host, path, status_code, ip, user_agent, referer, bytes_sent, duration_ms, query_params, bot_name, bot_provider, bot_types |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"bot_name","operator":"is","value":"GPTBot"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of bot log entries |
| `data` | json | Bot log data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values \(count\) |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_list_optimizations`
List content optimization entries for an asset in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `assetId` | string | Yes | Asset ID \(UUID\) |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
| `offset` | number | No | Offset for pagination \(default 0\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of optimization entries |
| `optimizations` | json | List of content optimization entries |
| ↳ `id` | string | Optimization ID \(UUID\) |
| ↳ `title` | string | Content title |
| ↳ `createdAt` | string | When the optimization was created |
| ↳ `extractedInput` | string | Extracted input text |
| ↳ `type` | string | Content type: file, text, or url |
| ↳ `status` | string | Optimization status |
### `profound_optimization_analysis`
Get detailed content optimization analysis for a specific content item in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `assetId` | string | Yes | Asset ID \(UUID\) |
| `contentId` | string | Yes | Content/optimization ID \(UUID\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | json | The analyzed content |
| ↳ `format` | string | Content format: markdown or html |
| ↳ `value` | string | Content text |
| `aeoContentScore` | json | AEO content score with target zone |
| ↳ `value` | number | AEO score value |
| ↳ `targetZone` | json | Target zone range |
| ↳ `low` | number | Low end of target range |
| ↳ `high` | number | High end of target range |
| `analysis` | json | Analysis breakdown by category |
| ↳ `breakdown` | json | Array of scoring breakdowns |
| ↳ `title` | string | Category title |
| ↳ `weight` | number | Category weight |
| ↳ `score` | number | Category score |
| `recommendations` | json | Content optimization recommendations |
| ↳ `title` | string | Recommendation title |
| ↳ `status` | string | Status: done or pending |
| ↳ `impact` | json | Impact details with section and score |
| ↳ `suggestion` | json | Suggestion text and rationale |
| ↳ `text` | string | Suggestion text |
| ↳ `rationale` | string | Why this recommendation matters |
### `profound_prompt_volume`
Query prompt volume data to understand search demand across AI platforms in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `startDate` | string | Yes | Start date \(YYYY-MM-DD or ISO 8601\) |
| `endDate` | string | Yes | End date \(YYYY-MM-DD or ISO 8601\) |
| `metrics` | string | Yes | Comma-separated metrics: volume, change |
| `dimensions` | string | No | Comma-separated dimensions: keyword, date, platform, country_code, matching_type, frequency |
| `dateInterval` | string | No | Date interval: hour, day, week, month, year |
| `filters` | string | No | JSON array of filter objects, e.g. \[\{"field":"keyword","operator":"contains","value":"best"\}\] |
| `limit` | number | No | Maximum number of results \(default 10000, max 50000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalRows` | number | Total number of rows in the report |
| `data` | json | Volume data rows with metrics and dimension values |
| ↳ `metrics` | json | Array of metric values matching requested metrics order |
| ↳ `dimensions` | json | Array of dimension values matching requested dimensions order |
### `profound_citation_prompts`
Get prompts that cite a specific domain across AI platforms in Profound
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Profound API Key |
| `inputDomain` | string | Yes | Domain to look up citations for \(e.g. ramp.com\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | json | Citation prompt data for the queried domain |

View File

@@ -1,6 +1,3 @@
/** Shared className for primary auth/status CTA buttons on dark auth surfaces. */
export const AUTH_PRIMARY_CTA_BASE =
'inline-flex h-[32px] items-center justify-center gap-2 rounded-[5px] border border-[var(--auth-primary-btn-border)] bg-[var(--auth-primary-btn-bg)] px-2.5 font-[430] font-season text-[var(--auth-primary-btn-text)] text-sm transition-colors hover:border-[var(--auth-primary-btn-hover-border)] hover:bg-[var(--auth-primary-btn-hover-bg)] hover:text-[var(--auth-primary-btn-hover-text)] disabled:cursor-not-allowed disabled:opacity-50' as const
/** Full-width variant used for primary auth form submit buttons. */
export const AUTH_SUBMIT_BTN = `${AUTH_PRIMARY_CTA_BASE} w-full` as const
/** Shared className for primary auth form submit buttons across all auth pages. */
export const AUTH_SUBMIT_BTN =
'inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50' as const

View File

@@ -288,6 +288,7 @@ export default function Collaboration() {
width={876}
height={480}
className='h-full w-auto object-left md:min-w-[100vw]'
priority
/>
</div>
<div className='hidden lg:block'>

View File

@@ -81,56 +81,6 @@ function ProviderPreviewIcon({ providerId }: { providerId?: string }) {
)
}
interface FeatureToggleItemProps {
feature: PermissionFeature
enabled: boolean
color: string
isInView: boolean
delay: number
textClassName: string
transition: Record<string, unknown>
onToggle: () => void
}
function FeatureToggleItem({
feature,
enabled,
color,
isInView,
delay,
textClassName,
transition,
onToggle,
}: FeatureToggleItemProps) {
return (
<motion.div
key={feature.key}
role='button'
tabIndex={0}
aria-label={`Toggle ${feature.name}`}
aria-pressed={enabled}
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
initial={{ opacity: 0, x: -6 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ ...transition, delay }}
onClick={onToggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}}
whileTap={{ scale: 0.98 }}
>
<CheckboxIcon checked={enabled} color={color} />
<ProviderPreviewIcon providerId={feature.providerId} />
<span className={textClassName} style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}>
{feature.name}
</span>
</motion.div>
)
}
export function AccessControlPanel() {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, margin: '-40px' })
@@ -147,25 +97,39 @@ export function AccessControlPanel() {
return (
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
{category.label}
</span>
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
{category.features.map((feature, featIdx) => (
<FeatureToggleItem
key={feature.key}
feature={feature}
enabled={accessState[feature.key]}
color={category.color}
isInView={isInView}
delay={0.05 + (offsetBefore + featIdx) * 0.04}
textClassName='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
transition={{ duration: 0.3 }}
onToggle={() =>
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
}
/>
))}
{category.features.map((feature, featIdx) => {
const enabled = accessState[feature.key]
return (
<motion.div
key={feature.key}
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
initial={{ opacity: 0, x: -6 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{
delay: 0.05 + (offsetBefore + featIdx) * 0.04,
duration: 0.3,
}}
onClick={() =>
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
}
whileTap={{ scale: 0.98 }}
>
<CheckboxIcon checked={enabled} color={category.color} />
<ProviderPreviewIcon providerId={feature.providerId} />
<span
className='truncate font-[430] font-season text-[13px] leading-none tracking-[0.02em]'
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
>
{feature.name}
</span>
</motion.div>
)
})}
</div>
</div>
)
@@ -176,11 +140,12 @@ export function AccessControlPanel() {
<div className='hidden lg:block'>
{PERMISSION_CATEGORIES.map((category, catIdx) => (
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
<span className='font-[430] font-season text-[#F6F6F6]/55 text-[10px] uppercase leading-none tracking-[0.08em]'>
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
{category.label}
</span>
<div className='mt-2 grid grid-cols-2 gap-x-4 gap-y-2'>
{category.features.map((feature, featIdx) => {
const enabled = accessState[feature.key]
const currentIndex =
PERMISSION_CATEGORIES.slice(0, catIdx).reduce(
(sum, c) => sum + c.features.length,
@@ -188,19 +153,30 @@ export function AccessControlPanel() {
) + featIdx
return (
<FeatureToggleItem
<motion.div
key={feature.key}
feature={feature}
enabled={accessState[feature.key]}
color={category.color}
isInView={isInView}
delay={0.1 + currentIndex * 0.04}
textClassName='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
transition={{ duration: 0.3, ease: [0.25, 0.46, 0.45, 0.94] }}
onToggle={() =>
className='flex cursor-pointer items-center gap-2 rounded-[4px] py-0.5'
initial={{ opacity: 0, x: -6 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{
delay: 0.1 + currentIndex * 0.04,
duration: 0.3,
ease: [0.25, 0.46, 0.45, 0.94],
}}
onClick={() =>
setAccessState((prev) => ({ ...prev, [feature.key]: !prev[feature.key] }))
}
/>
whileTap={{ scale: 0.98 }}
>
<CheckboxIcon checked={enabled} color={category.color} />
<ProviderPreviewIcon providerId={feature.providerId} />
<span
className='truncate font-[430] font-season text-[11px] leading-none tracking-[0.02em] transition-opacity duration-200'
style={{ color: enabled ? '#F6F6F6AA' : '#F6F6F640' }}
>
{feature.name}
</span>
</motion.div>
)
})}
</div>

View File

@@ -146,14 +146,14 @@ function AuditRow({ entry, index }: AuditRowProps) {
</div>
{/* Time */}
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/55 text-[11px] leading-none tracking-[0.02em]'>
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
{timeAgo}
</span>
<span className='min-w-0 truncate font-[430] font-season text-[12px] leading-none tracking-[0.02em]'>
<span className='text-[#F6F6F6]/80'>{entry.actor}</span>
<span className='hidden sm:inline'>
<span className='text-[#F6F6F6]/60'> · </span>
<span className='text-[#F6F6F6]/40'> · </span>
<span className='text-[#F6F6F6]/55'>{entry.description}</span>
</span>
</span>

View File

@@ -85,7 +85,7 @@ function TrustStrip() {
<strong className='font-[430] font-season text-small text-white leading-none'>
SOC 2 & HIPAA
</strong>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
Type II · PHI protected
</span>
</div>
@@ -105,7 +105,7 @@ function TrustStrip() {
<strong className='font-[430] font-season text-small text-white leading-none'>
Open Source
</strong>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
View on GitHub
</span>
</div>
@@ -120,7 +120,7 @@ function TrustStrip() {
<strong className='font-[430] font-season text-small text-white leading-none'>
SSO & SCIM
</strong>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em]'>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_30%,transparent)] text-xs leading-none tracking-[0.02em]'>
Okta, Azure AD, Google
</span>
</div>
@@ -165,7 +165,7 @@ export default function Enterprise() {
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
Audit Trail
</h3>
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
Every action is captured with full actor attribution.
</p>
</div>
@@ -179,7 +179,7 @@ export default function Enterprise() {
<h3 className='font-[430] font-season text-[16px] text-white leading-[120%] tracking-[-0.01em]'>
Access Control
</h3>
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/70 text-[14px] leading-[150%] tracking-[0.02em]'>
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
Restrict providers, surfaces, and tools per group.
</p>
</div>
@@ -211,7 +211,7 @@ export default function Enterprise() {
(tag, i) => (
<span
key={i}
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_80%,transparent)]'
className='enterprise-feature-marquee-tag whitespace-nowrap border-[var(--landing-bg-elevated)] border-r px-5 py-4 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_40%,transparent)] text-small leading-none tracking-[0.02em] hover:bg-white/[0.04] hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'
>
{tag}
</span>
@@ -221,7 +221,7 @@ export default function Enterprise() {
</div>
<div className='flex items-center justify-between border-[var(--landing-bg-elevated)] border-t px-6 py-5 md:px-8 md:py-6'>
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_40%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
Ready for growth?
</p>
<DemoRequestModal>

View File

@@ -190,6 +190,7 @@ export default function Features() {
width={1440}
height={366}
className='h-auto w-full'
priority
/>
</div>

View File

@@ -67,7 +67,6 @@ export function FooterCTA() {
type='button'
onClick={handleSubmit}
disabled={isEmpty}
aria-label='Submit message'
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
style={{
background: isEmpty ? '#C0C0C0' : '#1C1C1C',

View File

@@ -26,8 +26,6 @@ const RESOURCES_LINKS: FooterItem[] = [
{ label: 'Blog', href: '/blog' },
// { label: 'Templates', href: '/templates' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
// { label: 'Academy', href: '/academy' },
{ label: 'Partners', href: '/partners' },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
{ label: 'Changelog', href: '/changelog' },
]

View File

@@ -1,43 +0,0 @@
'use client'
import { useState } from 'react'
import NextImage from 'next/image'
import { cn } from '@/lib/core/utils/cn'
import { Lightbox } from '@/app/(landing)/blog/components/lightbox'
interface BlogImageProps {
src: string
alt?: string
width?: number
height?: number
className?: string
}
export function BlogImage({ src, alt = '', width = 800, height = 450, className }: BlogImageProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
return (
<>
<NextImage
src={src}
alt={alt}
width={width}
height={height}
className={cn(
'h-auto w-full cursor-pointer rounded-lg transition-opacity hover:opacity-95',
className
)}
sizes='(max-width: 768px) 100vw, 800px'
loading='lazy'
unoptimized
onClick={() => setIsLightboxOpen(true)}
/>
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={alt}
/>
</>
)
}

View File

@@ -1,62 +0,0 @@
'use client'
import { useEffect, useRef } from 'react'
interface LightboxProps {
isOpen: boolean
onClose: () => void
src: string
alt: string
}
export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
const overlayRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
const handleClickOutside = (event: MouseEvent) => {
if (overlayRef.current && event.target === overlayRef.current) {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('click', handleClickOutside)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('click', handleClickOutside)
document.body.style.overflow = 'unset'
}
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div
ref={overlayRef}
className='fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-12 backdrop-blur-sm'
role='dialog'
aria-modal='true'
aria-label='Image viewer'
>
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl shadow-2xl'>
<img
src={src}
alt={alt}
className='max-h-[75vh] max-w-[75vw] cursor-pointer rounded-xl object-contain'
loading='lazy'
onClick={onClose}
/>
</div>
</div>
)
}

View File

@@ -126,7 +126,6 @@ import {
PolymarketIcon,
PostgresIcon,
PosthogIcon,
ProfoundIcon,
PulseIcon,
QdrantIcon,
QuiverIcon,
@@ -303,7 +302,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
posthog: PosthogIcon,
profound: ProfoundIcon,
pulse_v2: PulseIcon,
qdrant: QdrantIcon,
quiver: QuiverIcon,

View File

@@ -2993,26 +2993,13 @@
"type": "file_v3",
"slug": "file",
"name": "File",
"description": "Read and write workspace files",
"longDescription": "Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.",
"description": "Read and parse multiple files",
"longDescription": "Upload files directly or import from external URLs to get UserFile objects for use in other blocks.",
"bgColor": "#40916C",
"iconName": "DocumentIcon",
"docsUrl": "https://docs.sim.ai/tools/file",
"operations": [
{
"name": "Read",
"description": "Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc.)"
},
{
"name": "Write",
"description": "Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g., "
},
{
"name": "Append",
"description": "Append content to an existing workspace file. The file must already exist. Content is added to the end of the file."
}
],
"operationCount": 3,
"operations": [],
"operationCount": 0,
"triggers": [],
"triggerCount": 0,
"authType": "none",
@@ -8624,121 +8611,6 @@
"integrationType": "analytics",
"tags": ["data-analytics", "monitoring"]
},
{
"type": "profound",
"slug": "profound",
"name": "Profound",
"description": "AI visibility and analytics with Profound",
"longDescription": "Track how your brand appears across AI platforms. Monitor visibility scores, sentiment, citations, bot traffic, referrals, content optimization, and prompt volumes with Profound.",
"bgColor": "#000000",
"iconName": "ProfoundIcon",
"docsUrl": "https://docs.sim.ai/tools/profound",
"operations": [
{
"name": "List Categories",
"description": "List all organization categories in Profound"
},
{
"name": "List Regions",
"description": "List all organization regions in Profound"
},
{
"name": "List Models",
"description": "List all AI models/platforms tracked in Profound"
},
{
"name": "List Domains",
"description": "List all organization domains in Profound"
},
{
"name": "List Assets",
"description": "List all organization assets (companies/brands) across all categories in Profound"
},
{
"name": "List Personas",
"description": "List all organization personas across all categories in Profound"
},
{
"name": "Category Topics",
"description": "List topics for a specific category in Profound"
},
{
"name": "Category Tags",
"description": "List tags for a specific category in Profound"
},
{
"name": "Category Prompts",
"description": "List prompts for a specific category in Profound"
},
{
"name": "Category Assets",
"description": "List assets (companies/brands) for a specific category in Profound"
},
{
"name": "Category Personas",
"description": "List personas for a specific category in Profound"
},
{
"name": "Visibility Report",
"description": "Query AI visibility report for a category in Profound"
},
{
"name": "Sentiment Report",
"description": "Query sentiment report for a category in Profound"
},
{
"name": "Citations Report",
"description": "Query citations report for a category in Profound"
},
{
"name": "Query Fanouts",
"description": "Query fanout report showing how AI models expand prompts into sub-queries in Profound"
},
{
"name": "Prompt Answers",
"description": "Get raw prompt answers data for a category in Profound"
},
{
"name": "Bots Report",
"description": "Query bot traffic report with hourly granularity for a domain in Profound"
},
{
"name": "Referrals Report",
"description": "Query human referral traffic report with hourly granularity for a domain in Profound"
},
{
"name": "Raw Logs",
"description": "Get raw traffic logs with filters for a domain in Profound"
},
{
"name": "Bot Logs",
"description": "Get identified bot visit logs with filters for a domain in Profound"
},
{
"name": "List Optimizations",
"description": "List content optimization entries for an asset in Profound"
},
{
"name": "Optimization Analysis",
"description": "Get detailed content optimization analysis for a specific content item in Profound"
},
{
"name": "Prompt Volume",
"description": "Query prompt volume data to understand search demand across AI platforms in Profound"
},
{
"name": "Citation Prompts",
"description": "Get prompts that cite a specific domain across AI platforms in Profound"
}
],
"operationCount": 24,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
"category": "tools",
"integrationType": "analytics",
"tags": ["seo", "data-analytics"]
},
{
"type": "pulse_v2",
"slug": "pulse",

View File

@@ -1,292 +0,0 @@
import type { Metadata } from 'next'
import { getNavBlogPosts } from '@/lib/blog/registry'
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export const metadata: Metadata = {
title: 'Partner Program',
description:
'Join the Sim partner program. Build, deploy, and sell AI workflow solutions. Earn your certification through Sim Academy.',
metadataBase: new URL('https://sim.ai'),
openGraph: {
title: 'Partner Program | Sim',
description: 'Join the Sim partner program.',
type: 'website',
},
}
const PARTNER_TIERS = [
{
name: 'Certified Partner',
badge: 'Entry',
color: '#3A3A3A',
requirements: ['Complete Sim Academy certification', 'Deploy at least 1 live workflow'],
perks: [
'Official partner badge',
'Listed in partner directory',
'Early access to new features',
],
},
{
name: 'Silver Partner',
badge: 'Growth',
color: '#5A5A5A',
requirements: [
'All Certified requirements',
'3+ active client deployments',
'Sim Academy advanced certification',
],
perks: [
'All Certified perks',
'Dedicated partner Slack channel',
'Co-marketing opportunities',
'Priority support',
],
},
{
name: 'Gold Partner',
badge: 'Premier',
color: '#8B7355',
requirements: [
'All Silver requirements',
'10+ active client deployments',
'Sim solutions architect certification',
],
perks: [
'All Silver perks',
'Revenue share program',
'Joint case studies',
'Dedicated partner success manager',
'Influence product roadmap',
],
},
]
const HOW_IT_WORKS = [
{
step: '01',
title: 'Sign up & complete Sim Academy',
description:
'Create an account and work through the Sim Academy certification program. Learn to build, integrate, and deploy AI workflows through hands-on canvas exercises.',
},
{
step: '02',
title: 'Build & deploy real solutions',
description:
'Put your skills to work. Build workflow automations for clients, integrate Sim into existing products, or create your own Sim-powered applications.',
},
{
step: '03',
title: 'Get certified & grow',
description:
'Earn your partner certification and unlock perks, co-marketing opportunities, and revenue share as you scale your practice.',
},
]
const BENEFITS = [
{
icon: '🎓',
title: 'Interactive Learning',
description:
'Learn on the real Sim canvas with drag-and-drop exercises, instant feedback, and guided exercises — not just videos.',
},
{
icon: '🤝',
title: 'Co-Marketing',
description:
'Get listed in the Sim partner directory, featured in case studies, and promoted to the Sim user base.',
},
{
icon: '💰',
title: 'Revenue Share',
description: 'Gold partners earn revenue share on referred customers and managed deployments.',
},
{
icon: '🚀',
title: 'Early Access',
description:
'Partners get early access to new Sim features, APIs, and integrations before they launch publicly.',
},
{
icon: '🛠️',
title: 'Technical Support',
description:
'Priority technical support, private Slack access, and a dedicated partner success manager for Gold partners.',
},
{
icon: '📣',
title: 'Community',
description:
'Join a growing community of Sim builders. Share workflows, collaborate on solutions, and shape the product roadmap.',
},
]
export default async function PartnersPage() {
const blogPosts = await getNavBlogPosts()
return (
<div
className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]`}
>
<header>
<Navbar logoOnly={false} blogPosts={blogPosts} />
</header>
<main>
{/* Hero */}
<section className='border-[#2A2A2A] border-b px-[80px] py-[100px]'>
<div className='mx-auto max-w-4xl'>
<div className='mb-4 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
Partner Program
</div>
<h1 className='mb-5 text-[64px] text-white leading-[105%] tracking-[-0.03em]'>
Build the future
<br />
of AI automation
</h1>
<p className='mb-10 max-w-xl text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
Become a certified Sim partner. Complete Sim Academy, deploy real solutions, and earn
recognition in the growing ecosystem of AI workflow builders.
</p>
<div className='flex items-center gap-4'>
{/* TODO: Uncomment when academy is public */}
{/* <Link
href='/academy'
className='inline-flex h-[44px] items-center rounded-[5px] bg-white px-6 text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
>
Start Sim Academy →
</Link> */}
<a
href='#how-it-works'
className='inline-flex h-[44px] items-center rounded-[5px] border border-[#3A3A3A] px-6 text-[#ECECEC] text-[15px] transition-colors hover:border-[#4A4A4A]'
>
Learn more
</a>
</div>
</div>
</section>
{/* Benefits grid */}
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
<div className='mx-auto max-w-5xl'>
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
Why partner with Sim
</div>
<div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
{BENEFITS.map((b) => (
<div key={b.title} className='rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'>
<div className='mb-3 text-[24px]'>{b.icon}</div>
<h3 className='mb-2 text-[#ECECEC] text-[15px]'>{b.title}</h3>
<p className='text-[#999] text-[14px] leading-[160%]'>{b.description}</p>
</div>
))}
</div>
</div>
</section>
{/* How it works */}
<section id='how-it-works' className='border-[#2A2A2A] border-b px-[80px] py-20'>
<div className='mx-auto max-w-4xl'>
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
How it works
</div>
<div className='space-y-10'>
{HOW_IT_WORKS.map((step) => (
<div key={step.step} className='flex gap-8'>
<div className='flex-shrink-0 font-[430] text-[#2A2A2A] text-[48px] leading-none'>
{step.step}
</div>
<div className='pt-2'>
<h3 className='mb-2 text-[#ECECEC] text-[18px]'>{step.title}</h3>
<p className='text-[#999] text-[15px] leading-[160%]'>{step.description}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* Partner tiers */}
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
<div className='mx-auto max-w-5xl'>
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
Partner tiers
</div>
<div className='grid gap-5 lg:grid-cols-3'>
{PARTNER_TIERS.map((tier) => (
<div
key={tier.name}
className='flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'
>
<div className='mb-4 flex items-center justify-between'>
<h3 className='text-[#ECECEC] text-[16px]'>{tier.name}</h3>
<span
className='rounded-full px-2.5 py-0.5 text-[11px]'
style={{
backgroundColor: `${tier.color}33`,
color: tier.color === '#8B7355' ? '#C8A96E' : '#999',
border: `1px solid ${tier.color}`,
}}
>
{tier.badge}
</span>
</div>
<div className='mb-4'>
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>
Requirements
</p>
<ul className='space-y-1.5'>
{tier.requirements.map((r) => (
<li key={r} className='flex items-start gap-2 text-[#999] text-[13px]'>
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#555]' />
{r}
</li>
))}
</ul>
</div>
<div className='mt-auto'>
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>Perks</p>
<ul className='space-y-1.5'>
{tier.perks.map((p) => (
<li key={p} className='flex items-start gap-2 text-[#ECECEC] text-[13px]'>
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#4CAF50]' />
{p}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className='px-[80px] py-[100px]'>
<div className='mx-auto max-w-3xl text-center'>
<h2 className='mb-4 text-[48px] text-white leading-[110%] tracking-[-0.02em]'>
Ready to get started?
</h2>
<p className='mb-10 text-[#F6F6F0]/60 text-[18px] leading-[160%]'>
Complete Sim Academy to earn your first certification and unlock partner benefits.
It's free to start — no credit card required.
</p>
{/* TODO: Uncomment when academy is public */}
{/* <Link
href='/academy'
className='inline-flex h-[48px] items-center rounded-[5px] bg-white px-8 font-[430] text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
>
Start Sim Academy
</Link> */}
</div>
</section>
</main>
<Footer />
</div>
)
}

View File

@@ -25,10 +25,6 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname.startsWith('/form') ||
pathname.startsWith('/oauth')
const isDarkModePage = pathname.startsWith('/academy')
const forcedTheme = isLightModePage ? 'light' : isDarkModePage ? 'dark' : undefined
return (
<NextThemesProvider
attribute='class'
@@ -36,7 +32,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
enableSystem
disableTransitionOnChange
storageKey='sim-theme'
forcedTheme={forcedTheme}
forcedTheme={isLightModePage ? 'light' : undefined}
{...props}
>
{children}

View File

@@ -15,12 +15,6 @@
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
--terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
--auth-primary-btn-bg: #ffffff;
--auth-primary-btn-border: #ffffff;
--auth-primary-btn-text: #000000;
--auth-primary-btn-hover-bg: #e0e0e0;
--auth-primary-btn-hover-border: #e0e0e0;
--auth-primary-btn-hover-text: #000000;
/* z-index scale for layered UI
Popover must be above modal so dropdowns inside modals render correctly */

View File

@@ -1,156 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { CheckCircle2, Circle, ExternalLink, GraduationCap, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { getCompletedLessons } from '@/lib/academy/local-progress'
import type { Course } from '@/lib/academy/types'
import { useSession } from '@/lib/auth/auth-client'
import { useCourseCertificate, useIssueCertificate } from '@/hooks/queries/academy'
interface CourseProgressProps {
course: Course
courseSlug: string
}
export function CourseProgress({ course, courseSlug }: CourseProgressProps) {
// Start with an empty set so SSR and initial client render match, then hydrate from localStorage.
const [completedIds, setCompletedIds] = useState<Set<string>>(() => new Set())
useEffect(() => {
setCompletedIds(getCompletedLessons())
}, [])
const { data: session } = useSession()
const { data: fetchedCert } = useCourseCertificate(session ? course.id : undefined)
const { mutate: issueCertificate, isPending, data: issuedCert, error } = useIssueCertificate()
const certificate = fetchedCert ?? issuedCert
const allLessons = course.modules.flatMap((m) => m.lessons)
const totalLessons = allLessons.length
const completedCount = allLessons.filter((l) => completedIds.has(l.id)).length
const percentComplete = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0
return (
<>
{completedCount > 0 && (
<div className='px-4 pt-8 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#2A2A2A] bg-[#222] p-4'>
<div className='mb-2 flex items-center justify-between text-[13px]'>
<span className='text-[#999]'>Your progress</span>
<span className='text-[#ECECEC]'>
{completedCount}/{totalLessons} lessons
</span>
</div>
<div className='h-1.5 w-full overflow-hidden rounded-full bg-[#2A2A2A]'>
<div
className='h-full rounded-full bg-[#ECECEC] transition-all'
style={{ width: `${percentComplete}%` }}
/>
</div>
</div>
</div>
)}
<section className='px-4 py-14 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl space-y-10'>
{course.modules.map((mod, modIndex) => (
<div key={mod.id}>
<div className='mb-4 flex items-center gap-3'>
<span className='text-[#555] text-[12px]'>Module {modIndex + 1}</span>
<div className='h-px flex-1 bg-[#2A2A2A]' />
</div>
<h2 className='mb-4 font-[430] text-[#ECECEC] text-[18px]'>{mod.title}</h2>
<div className='space-y-2'>
{mod.lessons.map((lesson) => (
<Link
key={lesson.id}
href={`/academy/${courseSlug}/${lesson.slug}`}
className='flex items-center gap-3 rounded-[8px] border border-[#2A2A2A] bg-[#222] px-4 py-3 text-[14px] transition-colors hover:border-[#3A3A3A] hover:bg-[#272727]'
>
{completedIds.has(lesson.id) ? (
<CheckCircle2 className='h-4 w-4 flex-shrink-0 text-[#4CAF50]' />
) : (
<Circle className='h-4 w-4 flex-shrink-0 text-[#444]' />
)}
<span className='flex-1 text-[#ECECEC]'>{lesson.title}</span>
<span className='text-[#555] text-[12px] capitalize'>{lesson.lessonType}</span>
{lesson.videoDurationSeconds && (
<span className='text-[#555] text-[12px]'>
{Math.round(lesson.videoDurationSeconds / 60)} min
</span>
)}
</Link>
))}
</div>
</div>
))}
</div>
</section>
{totalLessons > 0 && completedCount === totalLessons && (
<section className='px-4 pb-16 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#3A4A3A] bg-[#1F2A1F] p-6'>
{certificate ? (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
<div>
<p className='font-[430] text-[#ECECEC] text-[15px]'>Certificate issued!</p>
<p className='font-mono text-[#666] text-[13px]'>
{certificate.certificateNumber}
</p>
</div>
</div>
<Link
href={`/academy/certificate/${certificate.certificateNumber}`}
className='flex items-center gap-1.5 rounded-[5px] bg-[#4CAF50] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-[#5DBF61]'
>
View certificate
<ExternalLink className='h-3.5 w-3.5' />
</Link>
</div>
) : (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
<div>
<p className='font-[430] text-[#ECECEC] text-[15px]'>Course Complete!</p>
<p className='text-[#666] text-[13px]'>
{session
? error
? 'Something went wrong. Try again.'
: 'Claim your certificate of completion.'
: 'Sign in to claim your certificate.'}
</p>
</div>
</div>
{session ? (
<button
type='button'
disabled={isPending}
onClick={() =>
issueCertificate({
courseId: course.id,
completedLessonIds: [...completedIds],
})
}
className='flex items-center gap-2 rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white disabled:opacity-50'
>
{isPending && <Loader2 className='h-3.5 w-3.5 animate-spin' />}
{isPending ? 'Issuing…' : 'Get certificate'}
</button>
) : (
<Link
href='/login'
className='rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white'
>
Sign in
</Link>
)}
</div>
)}
</div>
</section>
)}
</>
)
}

View File

@@ -1,68 +0,0 @@
import { Clock, GraduationCap } from 'lucide-react'
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { COURSES, getCourse } from '@/lib/academy/content'
import { CourseProgress } from './components/course-progress'
interface CourseDetailPageProps {
params: Promise<{ courseSlug: string }>
}
export function generateStaticParams() {
return COURSES.map((course) => ({ courseSlug: course.slug }))
}
export async function generateMetadata({ params }: CourseDetailPageProps): Promise<Metadata> {
const { courseSlug } = await params
const course = getCourse(courseSlug)
if (!course) return { title: 'Course Not Found' }
return {
title: course.title,
description: course.description,
}
}
export default async function CourseDetailPage({ params }: CourseDetailPageProps) {
const { courseSlug } = await params
const course = getCourse(courseSlug)
if (!course) notFound()
return (
<main>
<section className='border-[#2A2A2A] border-b px-4 py-16 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl'>
<Link
href='/academy'
className='mb-4 inline-flex items-center gap-1.5 text-[#666] text-[13px] transition-colors hover:text-[#999]'
>
All courses
</Link>
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[36px] leading-[115%] tracking-[-0.02em]'>
{course.title}
</h1>
{course.description && (
<p className='mb-6 text-[#F6F6F0]/60 text-[16px] leading-[160%]'>
{course.description}
</p>
)}
<div className='mt-6 flex items-center gap-5 text-[#666] text-[13px]'>
{course.estimatedMinutes && (
<span className='flex items-center gap-1.5'>
<Clock className='h-3.5 w-3.5' />
{course.estimatedMinutes} min total
</span>
)}
<span className='flex items-center gap-1.5'>
<GraduationCap className='h-3.5 w-3.5' />
Certificate upon completion
</span>
</div>
</div>
</section>
<CourseProgress course={course} courseSlug={courseSlug} />
</main>
)
}

View File

@@ -1,127 +0,0 @@
import { cache } from 'react'
import { db } from '@sim/db'
import { academyCertificate } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { CheckCircle2, GraduationCap, XCircle } from 'lucide-react'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import type { AcademyCertificate } from '@/lib/academy/types'
interface CertificatePageProps {
params: Promise<{ certificateNumber: string }>
}
export async function generateMetadata({ params }: CertificatePageProps): Promise<Metadata> {
const { certificateNumber } = await params
const certificate = await fetchCertificate(certificateNumber)
if (!certificate) return { title: 'Certificate Not Found' }
return {
title: `${certificate.metadata?.courseTitle ?? 'Certificate'} — Certificate`,
description: `Verified certificate of completion awarded to ${certificate.metadata?.recipientName ?? 'a recipient'}.`,
}
}
const fetchCertificate = cache(
async (certificateNumber: string): Promise<AcademyCertificate | null> => {
const [row] = await db
.select()
.from(academyCertificate)
.where(eq(academyCertificate.certificateNumber, certificateNumber))
.limit(1)
return (row as unknown as AcademyCertificate) ?? null
}
)
const DATE_FORMAT: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }
function formatDate(date: string | Date) {
return new Date(date).toLocaleDateString('en-US', DATE_FORMAT)
}
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className='flex items-center justify-between px-5 py-3.5'>
<span className='text-[#666] text-[13px]'>{label}</span>
{children}
</div>
)
}
export default async function CertificatePage({ params }: CertificatePageProps) {
const { certificateNumber } = await params
const certificate = await fetchCertificate(certificateNumber)
if (!certificate) notFound()
return (
<main className='flex flex-1 items-center justify-center px-6 py-20'>
<div className='w-full max-w-2xl'>
<div className='rounded-[12px] border border-[#3A4A3A] bg-[#1C2A1C] p-10 text-center'>
<div className='mb-6 flex justify-center'>
<div className='flex h-16 w-16 items-center justify-center rounded-full border-2 border-[#4CAF50]/40 bg-[#4CAF50]/10'>
<GraduationCap className='h-8 w-8 text-[#4CAF50]' />
</div>
</div>
<div className='mb-2 text-[#4CAF50]/70 text-[13px] uppercase tracking-[0.12em]'>
Certificate of Completion
</div>
<h1 className='mb-1 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>
{certificate.metadata?.courseTitle}
</h1>
{certificate.metadata?.recipientName && (
<p className='mb-6 text-[#999] text-[16px]'>
Awarded to{' '}
<span className='text-[#ECECEC]'>{certificate.metadata.recipientName}</span>
</p>
)}
{certificate.status === 'active' ? (
<div className='flex items-center justify-center gap-2 text-[#4CAF50]'>
<CheckCircle2 className='h-4 w-4' />
<span className='font-[430] text-[14px]'>Verified</span>
</div>
) : (
<div className='flex items-center justify-center gap-2 text-[#f44336]'>
<XCircle className='h-4 w-4' />
<span className='font-[430] text-[14px] capitalize'>{certificate.status}</span>
</div>
)}
</div>
<div className='mt-6 divide-y divide-[#2A2A2A] rounded-[8px] border border-[#2A2A2A] bg-[#222]'>
<MetaRow label='Certificate number'>
<span className='font-mono text-[#ECECEC] text-[13px]'>
{certificate.certificateNumber}
</span>
</MetaRow>
<MetaRow label='Issued'>
<span className='text-[#ECECEC] text-[13px]'>{formatDate(certificate.issuedAt)}</span>
</MetaRow>
<MetaRow label='Status'>
<span
className={`text-[13px] capitalize ${
certificate.status === 'active' ? 'text-[#4CAF50]' : 'text-[#f44336]'
}`}
>
{certificate.status}
</span>
</MetaRow>
{certificate.expiresAt && (
<MetaRow label='Expires'>
<span className='text-[#ECECEC] text-[13px]'>
{formatDate(certificate.expiresAt)}
</span>
</MetaRow>
)}
</div>
<p className='mt-5 text-center text-[#555] text-[13px]'>
This certificate was issued by Sim AI, Inc. and verifies the holder has completed the{' '}
{certificate.metadata?.courseTitle} program.
</p>
</div>
</main>
)
}

View File

@@ -1,16 +0,0 @@
import type React from 'react'
import { getNavBlogPosts } from '@/lib/blog/registry'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default async function AcademyCatalogLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()
return (
<>
<Navbar blogPosts={blogPosts} />
{children}
<Footer hideCTA />
</>
)
}

View File

@@ -1,19 +0,0 @@
import Link from 'next/link'
export default function AcademyNotFound() {
return (
<main className='flex flex-1 flex-col items-center justify-center px-6 py-32 text-center'>
<p className='mb-2 font-mono text-[#555] text-[13px] uppercase tracking-widest'>404</p>
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>Page not found</h1>
<p className='mb-8 text-[#666] text-[15px]'>
That course or lesson doesn't exist in the Academy.
</p>
<Link
href='/academy'
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white'
>
Back to Academy
</Link>
</main>
)
}

View File

@@ -1,76 +0,0 @@
import { BookOpen, Clock } from 'lucide-react'
import Link from 'next/link'
import { COURSES } from '@/lib/academy/content'
export default function AcademyCatalogPage() {
return (
<main>
<section className='border-[#2A2A2A] border-b px-4 py-20 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl'>
<div className='mb-3 text-[#999] text-[13px] uppercase tracking-[0.12em]'>
Sim Academy
</div>
<h1 className='mb-4 font-[430] text-[#ECECEC] text-[48px] leading-[110%] tracking-[-0.02em]'>
Become a certified
<br />
Sim partner
</h1>
<p className='text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
Master AI workflow automation with hands-on interactive exercises on the real Sim
canvas. Complete the program to earn your partner certification.
</p>
</div>
</section>
<section className='px-4 py-16 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-6xl'>
<h2 className='mb-8 text-[#999] text-[13px] uppercase tracking-[0.12em]'>Courses</h2>
<div className='grid gap-5 sm:grid-cols-2 lg:grid-cols-3'>
{COURSES.map((course) => {
const totalLessons = course.modules.reduce((n, m) => n + m.lessons.length, 0)
return (
<Link
key={course.id}
href={`/academy/${course.slug}`}
className='group flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#232323] p-5 transition-colors hover:border-[#3A3A3A] hover:bg-[#282828]'
>
{course.imageUrl && (
<div className='mb-4 aspect-video w-full overflow-hidden rounded-[6px] bg-[#1A1A1A]'>
<img
src={course.imageUrl}
alt={course.title}
className='h-full w-full object-cover opacity-80'
/>
</div>
)}
<div className='flex-1'>
<h3 className='mb-2 font-[430] text-[#ECECEC] text-[16px] leading-[130%] group-hover:text-white'>
{course.title}
</h3>
{course.description && (
<p className='mb-4 line-clamp-2 text-[#999] text-[14px] leading-[150%]'>
{course.description}
</p>
)}
</div>
<div className='mt-auto flex items-center gap-4 text-[#666] text-[12px]'>
{course.estimatedMinutes && (
<span className='flex items-center gap-1.5'>
<Clock className='h-3 w-3' />
{course.estimatedMinutes} min
</span>
)}
<span className='flex items-center gap-1.5'>
<BookOpen className='h-3 w-3' />
{totalLessons} lessons
</span>
</div>
</Link>
)
})}
</div>
</div>
</section>
</main>
)
}

View File

@@ -1,66 +0,0 @@
'use client'
import { useCallback, useState } from 'react'
import { CheckCircle2 } from 'lucide-react'
import { markLessonComplete } from '@/lib/academy/local-progress'
import type { ExerciseBlockState, ExerciseDefinition, ExerciseEdgeState } from '@/lib/academy/types'
import { SandboxCanvasProvider } from '@/app/academy/components/sandbox-canvas-provider'
interface ExerciseViewProps {
lessonId: string
exerciseConfig: ExerciseDefinition
onComplete?: () => void
videoUrl?: string
description?: string
}
/**
* Orchestrates the sandbox canvas for an exercise lesson.
* Completion is determined client-side by the validation engine and persisted to localStorage.
*/
export function ExerciseView({
lessonId,
exerciseConfig,
onComplete,
videoUrl,
description,
}: ExerciseViewProps) {
const [completed, setCompleted] = useState(false)
// Reset completion banner when the lesson changes (component is reused across exercise navigations).
const [prevLessonId, setPrevLessonId] = useState(lessonId)
if (prevLessonId !== lessonId) {
setPrevLessonId(lessonId)
setCompleted(false)
}
const handleComplete = useCallback(
(_blocks: ExerciseBlockState[], _edges: ExerciseEdgeState[]) => {
setCompleted(true)
markLessonComplete(lessonId)
onComplete?.()
},
[lessonId, onComplete]
)
return (
<div className='relative flex h-full w-full flex-col overflow-hidden'>
<SandboxCanvasProvider
exerciseId={lessonId}
exerciseConfig={exerciseConfig}
onComplete={handleComplete}
videoUrl={videoUrl}
description={description}
className='flex-1'
/>
{completed && (
<div className='pointer-events-none absolute inset-0 flex items-start justify-center pt-5'>
<div className='pointer-events-auto flex items-center gap-2 rounded-full border border-[#3A4A3A] bg-[#1F2A1F]/95 px-4 py-2 font-[430] text-[#4CAF50] text-[13px] shadow-lg backdrop-blur-sm'>
<CheckCircle2 className='h-4 w-4' />
Exercise complete!
</div>
</div>
)}
</div>
)
}

View File

@@ -1,256 +0,0 @@
'use client'
import { useState } from 'react'
import { CheckCircle2, XCircle } from 'lucide-react'
import { markLessonComplete } from '@/lib/academy/local-progress'
import type { QuizDefinition, QuizQuestion } from '@/lib/academy/types'
import { cn } from '@/lib/core/utils/cn'
interface LessonQuizProps {
lessonId: string
quizConfig: QuizDefinition
onPass?: () => void
}
type Answers = Record<number, number | number[] | boolean>
interface QuizResult {
score: number
passed: boolean
feedback: Array<{ correct: boolean; explanation?: string }>
}
function scoreQuiz(questions: QuizQuestion[], answers: Answers, passingScore: number): QuizResult {
const feedback = questions.map((q, i) => {
const answer = answers[i]
let correct = false
if (q.type === 'multiple_choice') correct = answer === q.correctIndex
else if (q.type === 'true_false') correct = answer === q.correctAnswer
else if (q.type === 'multi_select') {
const selected = (answer as number[] | undefined) ?? []
correct =
selected.length === q.correctIndices.length &&
selected.every((v) => q.correctIndices.includes(v))
} else {
const _exhaustive: never = q
void _exhaustive
}
return { correct, explanation: 'explanation' in q ? q.explanation : undefined }
})
const score = Math.round((feedback.filter((f) => f.correct).length / questions.length) * 100)
return { score, passed: score >= passingScore, feedback }
}
const optionBase =
'w-full text-left rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default'
/**
* Interactive quiz component with per-question feedback and retry support.
* Scoring is performed entirely client-side.
*/
export function LessonQuiz({ lessonId, quizConfig, onPass }: LessonQuizProps) {
const [answers, setAnswers] = useState<Answers>({})
const [result, setResult] = useState<QuizResult | null>(null)
// Reset quiz state when the lesson changes (component is reused across quiz-lesson navigations).
const [prevLessonId, setPrevLessonId] = useState(lessonId)
if (prevLessonId !== lessonId) {
setPrevLessonId(lessonId)
setAnswers({})
setResult(null)
}
const handleAnswer = (qi: number, value: number | boolean) => {
if (!result) setAnswers((prev) => ({ ...prev, [qi]: value }))
}
const handleMultiSelect = (qi: number, oi: number) => {
if (result) return
setAnswers((prev) => {
const current = (prev[qi] as number[] | undefined) ?? []
const next = current.includes(oi) ? current.filter((i) => i !== oi) : [...current, oi]
return { ...prev, [qi]: next }
})
}
const allAnswered = quizConfig.questions.every((q, i) => {
if (q.type === 'multi_select')
return Array.isArray(answers[i]) && (answers[i] as number[]).length > 0
return answers[i] !== undefined
})
const handleSubmit = () => {
const scored = scoreQuiz(quizConfig.questions, answers, quizConfig.passingScore)
setResult(scored)
if (scored.passed) {
markLessonComplete(lessonId)
onPass?.()
}
}
return (
<div className='space-y-6'>
<div>
<h2 className='font-[430] text-[#ECECEC] text-[20px]'>Quiz</h2>
<p className='mt-1 text-[#666] text-[14px]'>
Score {quizConfig.passingScore}% or higher to pass.
</p>
</div>
{quizConfig.questions.map((q, qi) => {
const feedback = result?.feedback[qi]
const isCorrect = feedback?.correct
return (
<div key={qi} className='rounded-[8px] bg-[#222] p-5'>
<p className='mb-4 font-[430] text-[#ECECEC] text-[15px]'>{q.question}</p>
{q.type === 'multiple_choice' && (
<div className='space-y-2'>
{q.options.map((opt, oi) => (
<button
key={oi}
type='button'
onClick={() => handleAnswer(qi, oi)}
disabled={Boolean(result)}
className={cn(
optionBase,
answers[qi] === oi
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
result &&
oi === q.correctIndex &&
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
result &&
answers[qi] === oi &&
oi !== q.correctIndex &&
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
)}
>
{opt}
</button>
))}
</div>
)}
{q.type === 'true_false' && (
<div className='flex gap-3'>
{(['True', 'False'] as const).map((label) => {
const val = label === 'True'
return (
<button
key={label}
type='button'
onClick={() => handleAnswer(qi, val)}
disabled={Boolean(result)}
className={cn(
'flex-1 rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default',
answers[qi] === val
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
result &&
val === q.correctAnswer &&
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
result &&
answers[qi] === val &&
val !== q.correctAnswer &&
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
)}
>
{label}
</button>
)
})}
</div>
)}
{q.type === 'multi_select' && (
<div className='space-y-2'>
{q.options.map((opt, oi) => {
const selected = ((answers[qi] as number[]) ?? []).includes(oi)
return (
<button
key={oi}
type='button'
onClick={() => handleMultiSelect(qi, oi)}
disabled={Boolean(result)}
className={cn(
optionBase,
selected
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
result &&
q.correctIndices.includes(oi) &&
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
result &&
selected &&
!q.correctIndices.includes(oi) &&
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
)}
>
{opt}
</button>
)
})}
</div>
)}
{feedback && (
<div
className={cn(
'mt-3 flex items-start gap-2 rounded-[6px] px-3 py-2.5 text-[13px]',
isCorrect ? 'bg-[#4CAF50]/10 text-[#4CAF50]' : 'bg-[#f44336]/10 text-[#f44336]'
)}
>
{isCorrect ? (
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
) : (
<XCircle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
)}
<span>{isCorrect ? 'Correct!' : (feedback.explanation ?? 'Incorrect.')}</span>
</div>
)}
</div>
)
})}
{result && (
<div
className={cn(
'rounded-[8px] border p-5',
result.passed
? 'border-[#3A4A3A] bg-[#1F2A1F] text-[#4CAF50]'
: 'border-[#3A2A2A] bg-[#2A1F1F] text-[#f44336]'
)}
>
<p className='font-[430] text-[15px]'>{result.passed ? 'Passed!' : 'Keep trying!'}</p>
<p className='mt-1 text-[13px] opacity-80'>
Score: {result.score}% (passing: {quizConfig.passingScore}%)
</p>
{!result.passed && (
<button
type='button'
onClick={() => {
setAnswers({})
setResult(null)
}}
className='mt-3 rounded-[5px] border border-[#3A2A2A] bg-[#2A1F1F] px-3 py-1.5 text-[#999] text-[13px] transition-colors hover:border-[#4A3A3A] hover:text-[#ECECEC]'
>
Retry
</button>
)}
</div>
)}
{!result && (
<button
type='button'
onClick={handleSubmit}
disabled={!allAnswered}
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white disabled:opacity-40'
>
Submit answers
</button>
)}
</div>
)
}

View File

@@ -1,23 +0,0 @@
import type React from 'react'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
interface LessonLayoutProps {
children: React.ReactNode
params: Promise<{ courseSlug: string; lessonSlug: string }>
}
/**
* Server-side auth gate for lesson pages.
* Redirects unauthenticated users to login before any client JS runs.
*/
export default async function LessonLayout({ children, params }: LessonLayoutProps) {
const session = await getSession()
if (!session?.user?.id) {
const { courseSlug, lessonSlug } = await params
redirect(`/login?callbackUrl=/academy/${courseSlug}/${lessonSlug}`)
}
return <>{children}</>
}

View File

@@ -1,219 +0,0 @@
'use client'
import { use, useCallback, useEffect, useMemo, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { getCourse } from '@/lib/academy/content'
import { markLessonComplete } from '@/lib/academy/local-progress'
import type { Lesson } from '@/lib/academy/types'
import { LessonVideo } from '@/app/academy/components/lesson-video'
import { ExerciseView } from './components/exercise-view'
import { LessonQuiz } from './components/lesson-quiz'
const navBtnClass =
'flex items-center gap-1 rounded-[5px] border border-[#2A2A2A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
interface LessonPageProps {
params: Promise<{ courseSlug: string; lessonSlug: string }>
}
export default function LessonPage({ params }: LessonPageProps) {
const { courseSlug, lessonSlug } = use(params)
const course = getCourse(courseSlug)
const [exerciseComplete, setExerciseComplete] = useState(false)
const [quizComplete, setQuizComplete] = useState(false)
// Reset completion state when the lesson changes (Next.js reuses the component across navigations).
const [prevLessonSlug, setPrevLessonSlug] = useState(lessonSlug)
if (prevLessonSlug !== lessonSlug) {
setPrevLessonSlug(lessonSlug)
setExerciseComplete(false)
setQuizComplete(false)
}
const allLessons = useMemo<Lesson[]>(
() => course?.modules.flatMap((m) => m.lessons) ?? [],
[course]
)
const currentIndex = allLessons.findIndex((l) => l.slug === lessonSlug)
const lesson = allLessons[currentIndex]
const prevLesson = currentIndex > 0 ? allLessons[currentIndex - 1] : null
const nextLesson = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null
const handleExerciseComplete = useCallback(() => setExerciseComplete(true), [])
const handleQuizPass = useCallback(() => setQuizComplete(true), [])
const canAdvance =
(!lesson?.exerciseConfig && !lesson?.quizConfig) ||
(Boolean(lesson?.exerciseConfig) && Boolean(lesson?.quizConfig)
? exerciseComplete && quizComplete
: lesson?.exerciseConfig
? exerciseComplete
: quizComplete)
const isUngatedLesson =
lesson?.lessonType === 'video' ||
(lesson?.lessonType === 'mixed' && !lesson.exerciseConfig && !lesson.quizConfig)
useEffect(() => {
if (isUngatedLesson && lesson) {
markLessonComplete(lesson.id)
}
}, [lesson?.id, isUngatedLesson])
if (!course || !lesson) {
return (
<div className='flex h-screen items-center justify-center bg-[#1C1C1C]'>
<p className='text-[#666] text-[14px]'>Lesson not found.</p>
</div>
)
}
const hasVideo = Boolean(lesson.videoUrl)
const hasExercise = Boolean(lesson.exerciseConfig)
const hasQuiz = Boolean(lesson.quizConfig)
return (
<div className='fixed inset-0 flex flex-col overflow-hidden bg-[#1C1C1C]'>
<header className='flex h-[52px] flex-shrink-0 items-center justify-between border-[#2A2A2A] border-b bg-[#1C1C1C] px-5'>
<div className='flex items-center gap-3 text-[13px]'>
<Link href='/' aria-label='Sim home'>
<Image
src='/logo/b&w/text/b&w.svg'
alt='Sim'
width={40}
height={14}
className='opacity-70 invert transition-opacity hover:opacity-100'
/>
</Link>
<span className='text-[#333]'>/</span>
<Link href='/academy' className='text-[#666] transition-colors hover:text-[#999]'>
Academy
</Link>
<span className='text-[#333]'>/</span>
<Link
href={`/academy/${courseSlug}`}
className='max-w-[160px] truncate text-[#666] transition-colors hover:text-[#999]'
>
{course.title}
</Link>
<span className='text-[#333]'>/</span>
<span className='max-w-[200px] truncate text-[#ECECEC]'>{lesson.title}</span>
</div>
<div className='flex items-center gap-2'>
{prevLesson ? (
<Link href={`/academy/${courseSlug}/${prevLesson.slug}`} className={navBtnClass}>
<ChevronLeft className='h-3.5 w-3.5' />
Previous
</Link>
) : (
<Link href={`/academy/${courseSlug}`} className={navBtnClass}>
<ChevronLeft className='h-3.5 w-3.5' />
Course
</Link>
)}
{nextLesson && (
<Link
href={`/academy/${courseSlug}/${nextLesson.slug}`}
onClick={(e) => {
if (!canAdvance) e.preventDefault()
}}
className={`flex items-center gap-1 rounded-[5px] px-3 py-1.5 text-[12px] transition-colors ${
canAdvance
? 'bg-[#ECECEC] text-[#1C1C1C] hover:bg-white'
: 'cursor-not-allowed border border-[#2A2A2A] text-[#444]'
}`}
>
Next
<ChevronRight className='h-3.5 w-3.5' />
</Link>
)}
</div>
</header>
<div className='flex min-h-0 flex-1 overflow-hidden'>
{lesson.lessonType === 'video' && hasVideo && (
<div className='flex-1 overflow-y-auto p-10'>
<div className='mx-auto w-full max-w-3xl'>
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
{lesson.description && (
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>{lesson.description}</p>
)}
</div>
</div>
)}
{lesson.lessonType === 'exercise' && hasExercise && (
<ExerciseView
lessonId={lesson.id}
exerciseConfig={lesson.exerciseConfig!}
onComplete={handleExerciseComplete}
/>
)}
{lesson.lessonType === 'quiz' && hasQuiz && (
<div className='flex-1 overflow-y-auto p-10'>
<div className='mx-auto w-full max-w-2xl'>
<LessonQuiz
lessonId={lesson.id}
quizConfig={lesson.quizConfig!}
onPass={handleQuizPass}
/>
</div>
</div>
)}
{lesson.lessonType === 'mixed' && (
<>
{hasExercise && (!exerciseComplete || !hasQuiz) && (
<ExerciseView
lessonId={lesson.id}
exerciseConfig={lesson.exerciseConfig!}
onComplete={handleExerciseComplete}
videoUrl={!hasQuiz ? lesson.videoUrl : undefined}
description={!hasQuiz ? lesson.description : undefined}
/>
)}
{hasExercise && exerciseComplete && hasQuiz && (
<div className='flex-1 overflow-y-auto p-8'>
<div className='mx-auto w-full max-w-xl space-y-8'>
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
<LessonQuiz
lessonId={lesson.id}
quizConfig={lesson.quizConfig!}
onPass={handleQuizPass}
/>
</div>
</div>
)}
{!hasExercise && hasQuiz && (
<div className='flex-1 overflow-y-auto p-8'>
<div className='mx-auto w-full max-w-xl space-y-8'>
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
<LessonQuiz
lessonId={lesson.id}
quizConfig={lesson.quizConfig!}
onPass={handleQuizPass}
/>
</div>
</div>
)}
{!hasExercise && !hasQuiz && hasVideo && (
<div className='flex-1 overflow-y-auto p-10'>
<div className='mx-auto w-full max-w-3xl'>
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
{lesson.description && (
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>
{lesson.description}
</p>
)}
</div>
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@@ -1,56 +0,0 @@
'use client'
interface LessonVideoProps {
url: string
title: string
}
export function LessonVideo({ url, title }: LessonVideoProps) {
const embedUrl = resolveEmbedUrl(url)
if (!embedUrl) {
return (
<div className='flex aspect-video items-center justify-center rounded-lg bg-[#1A1A1A] text-[#666] text-sm'>
Video unavailable
</div>
)
}
return (
<div className='aspect-video w-full overflow-hidden rounded-lg bg-black'>
<iframe
src={embedUrl}
title={title}
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
allowFullScreen
className='h-full w-full border-0'
/>
</div>
)
}
function resolveEmbedUrl(url: string): string | null {
try {
const parsed = new URL(url)
if (parsed.hostname === 'youtu.be') {
return `https://www.youtube.com/embed${parsed.pathname}`
}
if (parsed.hostname.includes('youtube.com')) {
// Shorts: youtube.com/shorts/VIDEO_ID
const shortsMatch = parsed.pathname.match(/^\/shorts\/([^/?]+)/)
if (shortsMatch) return `https://www.youtube.com/embed/${shortsMatch[1]}`
const v = parsed.searchParams.get('v')
if (v) return `https://www.youtube.com/embed/${v}`
}
if (parsed.hostname === 'vimeo.com') {
const id = parsed.pathname.replace(/^\//, '')
if (id) return `https://player.vimeo.com/video/${id}`
}
return null
} catch (_e: unknown) {
return null
}
}

View File

@@ -1,432 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { Edge } from 'reactflow'
import { buildMockExecutionPlan } from '@/lib/academy/mock-execution'
import type {
ExerciseBlockState,
ExerciseDefinition,
ExerciseEdgeState,
ValidationResult,
} from '@/lib/academy/types'
import { validateExercise } from '@/lib/academy/validation'
import { cn } from '@/lib/core/utils/cn'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
import { getBlock } from '@/blocks/registry'
import { SandboxBlockConstraintsContext } from '@/hooks/use-sandbox-block-constraints'
import { useExecutionStore } from '@/stores/execution/store'
import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import { LessonVideo } from './lesson-video'
import { ValidationChecklist } from './validation-checklist'
const logger = createLogger('SandboxCanvasProvider')
const SANDBOX_WORKSPACE_ID = 'sandbox'
interface SandboxCanvasProviderProps {
/** Unique ID for this exercise instance */
exerciseId: string
/** Full exercise configuration */
exerciseConfig: ExerciseDefinition
/**
* Called when all validation rules pass for the first time.
* Receives the current canvas state so the caller can persist it.
*/
onComplete?: (blocks: ExerciseBlockState[], edges: ExerciseEdgeState[]) => void
/** Optional video URL (YouTube/Vimeo) shown above the checklist — used for mixed lessons */
videoUrl?: string
/** Optional description shown below the video (or below checklist if no video) */
description?: string
className?: string
}
/**
* Builds a Zustand-compatible WorkflowState from exercise block/edge definitions.
* Looks up each block type in the registry to construct proper sub-block and output maps.
*/
function buildWorkflowState(
initialBlocks: ExerciseBlockState[],
initialEdges: ExerciseEdgeState[]
): WorkflowState {
const blocks: Record<string, BlockState> = {}
for (const exerciseBlock of initialBlocks) {
const config = getBlock(exerciseBlock.type)
if (!config) {
logger.warn(`Unknown block type "${exerciseBlock.type}" in exercise config`)
continue
}
const subBlocks: Record<string, SubBlockState> = {}
for (const sb of config.subBlocks ?? []) {
const overrideValue = exerciseBlock.subBlocks?.[sb.id]
subBlocks[sb.id] = {
id: sb.id,
type: sb.type,
value: (overrideValue !== undefined ? overrideValue : null) as SubBlockState['value'],
}
}
const outputs = getEffectiveBlockOutputs(exerciseBlock.type, subBlocks, {
triggerMode: false,
preferToolOutputs: true,
})
blocks[exerciseBlock.id] = {
id: exerciseBlock.id,
type: exerciseBlock.type,
name: config.name,
position: exerciseBlock.position,
subBlocks,
outputs,
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode: false,
height: 0,
locked: exerciseBlock.locked ?? false,
}
}
const edges: Edge[] = initialEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle,
targetHandle: e.targetHandle,
type: 'default',
data: {},
}))
return { blocks, edges, loops: {}, parallels: {}, lastSaved: Date.now() }
}
/**
* Reads the current canvas state from the workflow store and converts it to
* the exercise block/edge format used by the validation engine.
*/
function readCurrentCanvasState(workflowId: string): {
blocks: ExerciseBlockState[]
edges: ExerciseEdgeState[]
} {
const workflowStore = useWorkflowStore.getState()
const subBlockStore = useSubBlockStore.getState()
const blocks: ExerciseBlockState[] = Object.values(workflowStore.blocks).map((block) => {
const storedValues = subBlockStore.workflowValues[workflowId] ?? {}
const blockValues = storedValues[block.id] ?? {}
const subBlocks: Record<string, unknown> = { ...blockValues }
return {
id: block.id,
type: block.type,
position: block.position,
subBlocks,
}
})
const edges: ExerciseEdgeState[] = workflowStore.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? undefined,
targetHandle: e.targetHandle ?? undefined,
}))
return { blocks, edges }
}
/**
* Wraps the real Sim canvas in sandbox mode for Sim Academy exercises.
*
* - Pre-hydrates workflow stores directly (no API calls)
* - Provides sandbox permissions (canEdit: true, no workspace dependency)
* - Displays a constrained block toolbar and live validation checklist
* - Supports mock execution to simulate workflow runs
*/
export function SandboxCanvasProvider({
exerciseId,
exerciseConfig,
onComplete,
videoUrl,
description,
className,
}: SandboxCanvasProviderProps) {
const [isReady, setIsReady] = useState(false)
const [validationResult, setValidationResult] = useState<ValidationResult>({
passed: false,
results: [],
})
const [hintIndex, setHintIndex] = useState(-1)
const completedRef = useRef(false)
const onCompleteRef = useRef(onComplete)
onCompleteRef.current = onComplete
const isMockRunningRef = useRef(false)
const handleMockRunRef = useRef<() => Promise<void>>(async () => {})
// Stable exercise ID — used as the workflow ID in the stores
const workflowId = `sandbox-${exerciseId}`
const runValidation = useCallback(() => {
const { blocks, edges } = readCurrentCanvasState(workflowId)
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
setValidationResult((prev) => {
if (
prev.passed === result.passed &&
prev.results.length === result.results.length &&
prev.results.every((r, i) => r.passed === result.results[i].passed)
) {
return prev
}
return result
})
if (result.passed && !completedRef.current) {
completedRef.current = true
onCompleteRef.current?.(blocks, edges)
}
}, [workflowId, exerciseConfig.validationRules])
useEffect(() => {
completedRef.current = false
setHintIndex(-1)
const workflowState = buildWorkflowState(
exerciseConfig.initialBlocks ?? [],
exerciseConfig.initialEdges ?? []
)
const syntheticMetadata: WorkflowMetadata = {
id: workflowId,
name: 'Exercise',
lastModified: new Date(),
createdAt: new Date(),
color: '#3972F6',
workspaceId: SANDBOX_WORKSPACE_ID,
sortOrder: 0,
isSandbox: true,
}
useWorkflowStore.getState().replaceWorkflowState(workflowState)
useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks)
useWorkflowRegistry.setState((state) => ({
workflows: { ...state.workflows, [workflowId]: syntheticMetadata },
activeWorkflowId: workflowId,
hydration: {
phase: 'ready',
workspaceId: SANDBOX_WORKSPACE_ID,
workflowId,
requestId: null,
error: null,
},
}))
logger.info('Sandbox stores hydrated', { workflowId })
setIsReady(true)
// Coalesce rapid store updates so validation runs at most once per animation frame.
let rafId: number | null = null
const scheduleValidation = () => {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
runValidation()
})
}
const unsubWorkflow = useWorkflowStore.subscribe(scheduleValidation)
const unsubSubBlock = useSubBlockStore.subscribe(scheduleValidation)
// When the panel's Run button is clicked, useWorkflowExecution sets isExecuting=true
// and returns immediately (no API call). Detect that signal here and run mock execution.
const unsubExecution = useExecutionStore.subscribe((state) => {
const isExec = state.workflowExecutions.get(workflowId)?.isExecuting
if (isExec && !isMockRunningRef.current) {
void handleMockRunRef.current()
}
})
runValidation()
return () => {
if (rafId !== null) cancelAnimationFrame(rafId)
unsubWorkflow()
unsubSubBlock()
unsubExecution()
useWorkflowRegistry.setState((state) => {
const { [workflowId]: _removed, ...rest } = state.workflows
return {
workflows: rest,
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
hydration:
state.hydration.workflowId === workflowId
? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null }
: state.hydration,
}
})
useWorkflowStore.setState({ blocks: {}, edges: [], loops: {}, parallels: {} })
useSubBlockStore.setState((state) => {
const { [workflowId]: _removed, ...rest } = state.workflowValues
return { workflowValues: rest }
})
}
}, [workflowId, exerciseConfig.initialBlocks, exerciseConfig.initialEdges, runValidation])
const handleMockRun = useCallback(async () => {
if (isMockRunningRef.current) return
isMockRunningRef.current = true
const { setActiveBlocks, setIsExecuting } = useExecutionStore.getState()
const { blocks, edges } = readCurrentCanvasState(workflowId)
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
setValidationResult(result)
if (!result.passed) {
isMockRunningRef.current = false
setIsExecuting(workflowId, false)
return
}
const plan = buildMockExecutionPlan(blocks, edges, exerciseConfig.mockOutputs ?? {})
if (plan.length === 0) {
isMockRunningRef.current = false
setIsExecuting(workflowId, false)
return
}
const { addConsole, clearWorkflowConsole } = useTerminalConsoleStore.getState()
const workflowBlocks = useWorkflowStore.getState().blocks
setIsExecuting(workflowId, true)
clearWorkflowConsole(workflowId)
useTerminalConsoleStore.setState({ isOpen: true })
try {
for (let i = 0; i < plan.length; i++) {
const step = plan[i]
setActiveBlocks(workflowId, new Set([step.blockId]))
await new Promise((resolve) => setTimeout(resolve, step.delay))
addConsole({
workflowId,
blockId: step.blockId,
blockName: workflowBlocks[step.blockId]?.name ?? step.blockType,
blockType: step.blockType,
executionOrder: i,
output: step.output,
success: true,
durationMs: step.delay,
})
setActiveBlocks(workflowId, new Set())
}
} finally {
setIsExecuting(workflowId, false)
isMockRunningRef.current = false
}
}, [workflowId, exerciseConfig.validationRules, exerciseConfig.mockOutputs])
handleMockRunRef.current = handleMockRun
const handleShowHint = useCallback(() => {
const hints = exerciseConfig.hints ?? []
if (hints.length === 0) return
setHintIndex((i) => Math.min(i + 1, hints.length - 1))
}, [exerciseConfig.hints])
const handlePrevHint = useCallback(() => {
setHintIndex((i) => Math.max(i - 1, 0))
}, [])
if (!isReady) {
return (
<div className='flex h-full w-full items-center justify-center bg-[#0e0e0e]'>
<div className='h-5 w-5 animate-spin rounded-full border-2 border-[#ECECEC] border-t-transparent' />
</div>
)
}
const hints = exerciseConfig.hints ?? []
const currentHint = hintIndex >= 0 ? hints[hintIndex] : null
return (
<SandboxBlockConstraintsContext.Provider value={exerciseConfig.availableBlocks}>
<GlobalCommandsProvider>
<SandboxWorkspacePermissionsProvider>
<div className={cn('flex h-full w-full overflow-hidden', className)}>
<div className='flex w-56 flex-shrink-0 flex-col gap-3 overflow-y-auto border-[#1F1F1F] border-r bg-[#141414] p-3'>
{(videoUrl || description) && (
<div className='flex flex-col gap-2'>
{videoUrl && <LessonVideo url={videoUrl} title='Lesson video' />}
{description && (
<p className='text-[#666] text-[11px] leading-relaxed'>{description}</p>
)}
<div className='border-[#1F1F1F] border-t' />
</div>
)}
{exerciseConfig.instructions && (
<p className='text-[#999] text-[11px] leading-relaxed'>
{exerciseConfig.instructions}
</p>
)}
<ValidationChecklist
results={validationResult.results}
allPassed={validationResult.passed}
/>
<div className='mt-auto flex flex-col gap-2'>
{currentHint && (
<div className='rounded-[6px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-2 text-[11px]'>
<div className='mb-1 flex items-center justify-between'>
<span className='font-[430] text-[#666]'>
Hint {hintIndex + 1}/{hints.length}
</span>
<div className='flex gap-1'>
<button
type='button'
onClick={handlePrevHint}
disabled={hintIndex === 0}
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
aria-label='Previous hint'
>
</button>
<button
type='button'
onClick={handleShowHint}
disabled={hintIndex === hints.length - 1}
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
aria-label='Next hint'
>
</button>
</div>
</div>
<span className='text-[#ECECEC]'>{currentHint}</span>
</div>
)}
{hints.length > 0 && hintIndex < 0 && (
<button
type='button'
onClick={handleShowHint}
className='w-full rounded-[5px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
>
Show hint
</button>
)}
</div>
</div>
<div className='relative flex-1 overflow-hidden'>
<Workflow workspaceId={SANDBOX_WORKSPACE_ID} workflowId={workflowId} sandbox />
</div>
</div>
</SandboxWorkspacePermissionsProvider>
</GlobalCommandsProvider>
</SandboxBlockConstraintsContext.Provider>
)
}

View File

@@ -1,50 +0,0 @@
'use client'
import { CheckCircle2, Circle } from 'lucide-react'
import type { ValidationRuleResult } from '@/lib/academy/types'
import { cn } from '@/lib/core/utils/cn'
interface ValidationChecklistProps {
results: ValidationRuleResult[]
allPassed: boolean
}
/**
* Checklist showing exercise validation rules and their current pass/fail state.
* Rendered inside the exercise sidebar, not as a canvas overlay.
*/
export function ValidationChecklist({ results, allPassed }: ValidationChecklistProps) {
if (results.length === 0) return null
return (
<div>
<div className='mb-2.5 flex items-center gap-1.5'>
<span className='font-[430] text-[#ECECEC] text-[12px]'>Checklist</span>
{allPassed && (
<span className='ml-auto rounded-full bg-[#4CAF50]/15 px-2 py-0.5 font-[430] text-[#4CAF50] text-[10px]'>
Complete
</span>
)}
</div>
<ul className='space-y-1.5'>
{results.map((result, i) => (
<li key={i} className='flex items-start gap-2'>
{result.passed ? (
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#4CAF50]' />
) : (
<Circle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#444]' />
)}
<span
className={cn(
'text-[11px] leading-tight',
result.passed ? 'text-[#555] line-through' : 'text-[#ECECEC]'
)}
>
{result.message}
</span>
</li>
))}
</ul>
</div>
)
}

View File

@@ -1,33 +0,0 @@
import type React from 'react'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
// TODO: Remove notFound() call to make academy pages public once content is ready
const ACADEMY_ENABLED = false
export const metadata: Metadata = {
title: {
absolute: 'Sim Academy',
template: '%s | Sim Academy',
},
description:
'Become a certified Sim partner — learn to build, integrate, and deploy AI workflows.',
metadataBase: new URL('https://sim.ai'),
openGraph: {
title: 'Sim Academy',
description: 'Become a certified Sim partner.',
type: 'website',
},
}
export default function AcademyLayout({ children }: { children: React.ReactNode }) {
if (!ACADEMY_ENABLED) {
notFound()
}
return (
<div className='min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
{children}
</div>
)
}

View File

@@ -1,215 +0,0 @@
import { db } from '@sim/db'
import { academyCertificate, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getCourseById } from '@/lib/academy/content'
import type { CertificateMetadata } from '@/lib/academy/types'
import { getSession } from '@/lib/auth'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
const logger = createLogger('AcademyCertificatesAPI')
const rateLimiter = new RateLimiter()
const CERT_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 5,
refillRate: 1,
refillIntervalMs: 60 * 60_000, // 1 per hour refill
}
const IssueCertificateSchema = z.object({
courseId: z.string(),
completedLessonIds: z.array(z.string()),
})
/**
* POST /api/academy/certificates
* Issues a certificate for the given course after verifying all lessons are completed.
* Completion is client-attested: the client sends completed lesson IDs and the server
* validates them against the full lesson list for the course.
*/
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { allowed } = await rateLimiter.checkRateLimitDirect(
`academy:cert:${session.user.id}`,
CERT_RATE_LIMIT
)
if (!allowed) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
}
const body = await req.json()
const parsed = IssueCertificateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const { courseId, completedLessonIds } = parsed.data
const course = getCourseById(courseId)
if (!course) {
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
}
// Verify all lessons in the course are reported as completed
const allLessonIds = course.modules.flatMap((m) => m.lessons.map((l) => l.id))
const completedSet = new Set(completedLessonIds)
const incomplete = allLessonIds.filter((id) => !completedSet.has(id))
if (incomplete.length > 0) {
return NextResponse.json({ error: 'Course not fully completed', incomplete }, { status: 422 })
}
const [existing, learner] = await Promise.all([
db
.select()
.from(academyCertificate)
.where(
and(
eq(academyCertificate.userId, session.user.id),
eq(academyCertificate.courseId, courseId)
)
)
.limit(1)
.then((rows) => rows[0] ?? null),
db
.select({ name: user.name })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
.then((rows) => rows[0] ?? null),
])
if (existing) {
if (existing.status === 'active') {
return NextResponse.json({ certificate: existing })
}
return NextResponse.json(
{ error: 'A certificate for this course already exists but is not active.' },
{ status: 409 }
)
}
const certificateNumber = generateCertificateNumber()
const metadata: CertificateMetadata = {
recipientName: learner?.name ?? session.user.name ?? 'Partner',
courseTitle: course.title,
}
const [certificate] = await db
.insert(academyCertificate)
.values({
id: nanoid(),
userId: session.user.id,
courseId,
status: 'active',
certificateNumber,
metadata,
})
.onConflictDoNothing()
.returning()
if (!certificate) {
const [race] = await db
.select()
.from(academyCertificate)
.where(
and(
eq(academyCertificate.userId, session.user.id),
eq(academyCertificate.courseId, courseId)
)
)
.limit(1)
if (race?.status === 'active') {
return NextResponse.json({ certificate: race })
}
if (race) {
return NextResponse.json(
{ error: 'A certificate for this course already exists but is not active.' },
{ status: 409 }
)
}
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
}
logger.info('Certificate issued', {
userId: session.user.id,
courseId,
certificateNumber,
})
return NextResponse.json({ certificate }, { status: 201 })
} catch (error) {
logger.error('Failed to issue certificate', { error })
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
}
}
/**
* GET /api/academy/certificates?certificateNumber=SIM-2026-00042
* Public endpoint for verifying a certificate by its number.
*
* GET /api/academy/certificates?courseId=...
* Authenticated endpoint for looking up the current user's certificate for a course.
*/
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const certificateNumber = searchParams.get('certificateNumber')
const courseId = searchParams.get('courseId')
if (certificateNumber) {
const [certificate] = await db
.select()
.from(academyCertificate)
.where(eq(academyCertificate.certificateNumber, certificateNumber))
.limit(1)
if (!certificate) {
return NextResponse.json({ error: 'Certificate not found' }, { status: 404 })
}
return NextResponse.json({ certificate })
}
if (courseId) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [certificate] = await db
.select()
.from(academyCertificate)
.where(
and(
eq(academyCertificate.userId, session.user.id),
eq(academyCertificate.courseId, courseId)
)
)
.limit(1)
return NextResponse.json({ certificate: certificate ?? null })
}
return NextResponse.json(
{ error: 'certificateNumber or courseId query parameter is required' },
{ status: 400 }
)
} catch (error) {
logger.error('Failed to verify certificate', { error })
return NextResponse.json({ error: 'Failed to verify certificate' }, { status: 500 })
}
}
/** Generates a human-readable certificate number, e.g. SIM-2026-A3K9XZ2P */
function generateCertificateNumber(): string {
const year = new Date().getFullYear()
return `SIM-${year}-${nanoid(8).toUpperCase()}`
}

View File

@@ -15,12 +15,12 @@ const {
mockLimit,
mockUpdate,
mockSet,
mockDelete,
mockCreateSuccessResponse,
mockCreateErrorResponse,
mockEncryptSecret,
mockCheckChatAccess,
mockDeployWorkflow,
mockPerformChatUndeploy,
mockLogger,
} = vi.hoisted(() => {
const logger = {
@@ -40,12 +40,12 @@ const {
mockLimit: vi.fn(),
mockUpdate: vi.fn(),
mockSet: vi.fn(),
mockDelete: vi.fn(),
mockCreateSuccessResponse: vi.fn(),
mockCreateErrorResponse: vi.fn(),
mockEncryptSecret: vi.fn(),
mockCheckChatAccess: vi.fn(),
mockDeployWorkflow: vi.fn(),
mockPerformChatUndeploy: vi.fn(),
mockLogger: logger,
}
})
@@ -66,6 +66,7 @@ vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
update: mockUpdate,
delete: mockDelete,
},
}))
vi.mock('@sim/db/schema', () => ({
@@ -87,9 +88,6 @@ vi.mock('@/app/api/chat/utils', () => ({
vi.mock('@/lib/workflows/persistence/utils', () => ({
deployWorkflow: mockDeployWorkflow,
}))
vi.mock('@/lib/workflows/orchestration', () => ({
performChatUndeploy: mockPerformChatUndeploy,
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
@@ -108,7 +106,7 @@ describe('Chat Edit API Route', () => {
mockWhere.mockReturnValue({ limit: mockLimit })
mockUpdate.mockReturnValue({ set: mockSet })
mockSet.mockReturnValue({ where: mockWhere })
mockPerformChatUndeploy.mockResolvedValue({ success: true })
mockDelete.mockReturnValue({ where: mockWhere })
mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
@@ -430,11 +428,7 @@ describe('Chat Edit API Route', () => {
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
chatId: 'chat-123',
userId: 'user-id',
workspaceId: 'workspace-123',
})
expect(mockDelete).toHaveBeenCalled()
const data = await response.json()
expect(data.message).toBe('Chat deployment deleted successfully')
})
@@ -457,11 +451,7 @@ describe('Chat Edit API Route', () => {
expect(response.status).toBe(200)
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
chatId: 'chat-123',
userId: 'admin-user-id',
workspaceId: 'workspace-123',
})
expect(mockDelete).toHaveBeenCalled()
})
})
})

View File

@@ -9,7 +9,6 @@ import { getSession } from '@/lib/auth'
import { isDev } from '@/lib/core/config/feature-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { performChatUndeploy } from '@/lib/workflows/orchestration'
import { deployWorkflow } from '@/lib/workflows/persistence/utils'
import { checkChatAccess } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -271,24 +270,32 @@ export async function DELETE(
return createErrorResponse('Unauthorized', 401)
}
const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess(
chatId,
session.user.id
)
const {
hasAccess,
chat: chatRecord,
workspaceId: chatWorkspaceId,
} = await checkChatAccess(chatId, session.user.id)
if (!hasAccess) {
return createErrorResponse('Chat not found or access denied', 404)
}
const result = await performChatUndeploy({
chatId,
userId: session.user.id,
workspaceId: chatWorkspaceId,
})
await db.delete(chat).where(eq(chat.id, chatId))
if (!result.success) {
return createErrorResponse(result.error || 'Failed to delete chat', 500)
}
logger.info(`Chat "${chatId}" deleted successfully`)
recordAudit({
workspaceId: chatWorkspaceId || null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CHAT_DELETED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: chatRecord?.title || chatId,
description: `Deleted chat deployment "${chatRecord?.title || chatId}"`,
request: _request,
})
return createSuccessResponse({
message: 'Chat deployment deleted successfully',

View File

@@ -3,7 +3,7 @@
*
* @vitest-environment node
*/
import { createEnvMock } from '@sim/testing'
import { auditMock, createEnvMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -12,51 +12,66 @@ const {
mockFrom,
mockWhere,
mockLimit,
mockInsert,
mockValues,
mockReturning,
mockCreateSuccessResponse,
mockCreateErrorResponse,
mockEncryptSecret,
mockCheckWorkflowAccessForChatCreation,
mockPerformChatDeploy,
mockDeployWorkflow,
mockGetSession,
mockUuidV4,
} = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockLimit: vi.fn(),
mockInsert: vi.fn(),
mockValues: vi.fn(),
mockReturning: vi.fn(),
mockCreateSuccessResponse: vi.fn(),
mockCreateErrorResponse: vi.fn(),
mockEncryptSecret: vi.fn(),
mockCheckWorkflowAccessForChatCreation: vi.fn(),
mockPerformChatDeploy: vi.fn(),
mockDeployWorkflow: vi.fn(),
mockGetSession: vi.fn(),
mockUuidV4: vi.fn(),
}))
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
insert: mockInsert,
},
}))
vi.mock('@sim/db/schema', () => ({
chat: { userId: 'userId', identifier: 'identifier', archivedAt: 'archivedAt' },
chat: { userId: 'userId', identifier: 'identifier' },
workflow: { id: 'id', userId: 'userId', isDeployed: 'isDeployed' },
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
}))
vi.mock('@/app/api/workflows/utils', () => ({
createSuccessResponse: mockCreateSuccessResponse,
createErrorResponse: mockCreateErrorResponse,
}))
vi.mock('@/lib/core/security/encryption', () => ({
encryptSecret: mockEncryptSecret,
}))
vi.mock('uuid', () => ({
v4: mockUuidV4,
}))
vi.mock('@/app/api/chat/utils', () => ({
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
}))
vi.mock('@/lib/workflows/orchestration', () => ({
performChatDeploy: mockPerformChatDeploy,
vi.mock('@/lib/workflows/persistence/utils', () => ({
deployWorkflow: mockDeployWorkflow,
}))
vi.mock('@/lib/auth', () => ({
@@ -79,6 +94,10 @@ describe('Chat API Route', () => {
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ limit: mockLimit })
mockInsert.mockReturnValue({ values: mockValues })
mockValues.mockReturnValue({ returning: mockReturning })
mockUuidV4.mockReturnValue('test-uuid')
mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
@@ -94,10 +113,12 @@ describe('Chat API Route', () => {
})
})
mockPerformChatDeploy.mockResolvedValue({
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' })
mockDeployWorkflow.mockResolvedValue({
success: true,
chatId: 'test-uuid',
chatUrl: 'http://localhost:3000/chat/test-chat',
version: 1,
deployedAt: new Date(),
})
})
@@ -256,6 +277,7 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -265,13 +287,6 @@ describe('Chat API Route', () => {
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-id',
identifier: 'test-chat',
})
)
})
it('should allow chat deployment when user has workspace admin permission', async () => {
@@ -294,6 +309,7 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -303,12 +319,6 @@ describe('Chat API Route', () => {
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
workspaceId: 'workspace-123',
})
)
})
it('should reject when workflow is in workspace but user lacks admin permission', async () => {
@@ -373,7 +383,7 @@ describe('Chat API Route', () => {
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should call performChatDeploy for undeployed workflow', async () => {
it('should auto-deploy workflow if not already deployed', async () => {
mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})
@@ -393,6 +403,7 @@ describe('Chat API Route', () => {
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: false },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -401,12 +412,10 @@ describe('Chat API Route', () => {
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-id',
})
)
expect(mockDeployWorkflow).toHaveBeenCalledWith({
workflowId: 'workflow-123',
deployedBy: 'user-id',
})
})
})
})

View File

@@ -3,9 +3,14 @@ import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { performChatDeploy } from '@/lib/workflows/orchestration'
import { isDev } from '@/lib/core/config/feature-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { deployWorkflow } from '@/lib/workflows/persistence/utils'
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -104,6 +109,7 @@ export async function POST(request: NextRequest) {
)
}
// Check identifier availability and workflow access in parallel
const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([
db
.select()
@@ -121,27 +127,121 @@ export async function POST(request: NextRequest) {
return createErrorResponse('Workflow not found or access denied', 404)
}
const result = await performChatDeploy({
// Always deploy/redeploy the workflow to ensure latest version
const result = await deployWorkflow({
workflowId,
deployedBy: session.user.id,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to deploy workflow', 500)
}
logger.info(
`${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for chat (v${result.version})`
)
// Encrypt password if provided
let encryptedPassword = null
if (authType === 'password' && password) {
const { encrypted } = await encryptSecret(password)
encryptedPassword = encrypted
}
// Create the chat deployment
const id = uuidv4()
// Log the values we're inserting
logger.info('Creating chat deployment with values:', {
workflowId,
identifier,
title,
authType,
hasPassword: !!encryptedPassword,
emailCount: allowedEmails?.length || 0,
outputConfigsCount: outputConfigs.length,
})
// Merge customizations with the additional fields
const mergedCustomizations = {
...(customizations || {}),
primaryColor: customizations?.primaryColor || 'var(--brand-hover)',
welcomeMessage: customizations?.welcomeMessage || 'Hi there! How can I help you today?',
}
await db.insert(chat).values({
id,
workflowId,
userId: session.user.id,
identifier,
title,
description,
customizations,
description: description || null,
customizations: mergedCustomizations,
isActive: true,
authType,
password,
allowedEmails,
password: encryptedPassword,
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
outputConfigs,
workspaceId: workflowRecord.workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to deploy chat', 500)
// Return successful response with chat URL
// Generate chat URL using path-based routing instead of subdomains
const baseUrl = getBaseUrl()
let chatUrl: string
try {
const url = new URL(baseUrl)
let host = url.host
if (host.startsWith('www.')) {
host = host.substring(4)
}
chatUrl = `${url.protocol}//${host}/chat/${identifier}`
} catch (error) {
logger.warn('Failed to parse baseUrl, falling back to defaults:', {
baseUrl,
error: error instanceof Error ? error.message : 'Unknown error',
})
// Fallback based on environment
if (isDev) {
chatUrl = `http://localhost:3000/chat/${identifier}`
} else {
chatUrl = `https://sim.ai/chat/${identifier}`
}
}
logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.chatDeployed({
chatId: id,
workflowId,
authType,
hasOutputConfigs: outputConfigs.length > 0,
})
} catch (_e) {
// Silently fail
}
recordAudit({
workspaceId: workflowRecord.workspaceId || null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CHAT_DEPLOYED,
resourceType: AuditResourceType.CHAT,
resourceId: id,
resourceName: title,
description: `Deployed chat "${title}"`,
metadata: { workflowId, identifier, authType },
request,
})
return createSuccessResponse({
id: result.chatId,
chatUrl: result.chatUrl,
id,
chatUrl,
message: 'Chat deployment created successfully',
})
} catch (validationError) {

View File

@@ -15,6 +15,7 @@ import {
requestChatTitle,
SSE_RESPONSE_HEADERS,
} from '@/lib/copilot/chat-streaming'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
@@ -183,31 +184,36 @@ export async function POST(req: NextRequest) {
const wf = await getWorkflowById(workflowId)
resolvedWorkspaceId = wf?.workspaceId ?? undefined
} catch {
logger
.withMetadata({ requestId: tracker.requestId, messageId: userMessageId })
.warn('Failed to resolve workspaceId from workflow')
logger.warn(
appendCopilotLogContext('Failed to resolve workspaceId from workflow', {
requestId: tracker.requestId,
messageId: userMessageId,
})
)
}
const userMessageIdToUse = userMessageId || crypto.randomUUID()
const reqLogger = logger.withMetadata({
requestId: tracker.requestId,
messageId: userMessageIdToUse,
})
try {
reqLogger.info('Received chat POST', {
workflowId,
hasContexts: Array.isArray(normalizedContexts),
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
contextsPreview: Array.isArray(normalizedContexts)
? normalizedContexts.map((c: any) => ({
kind: c?.kind,
chatId: c?.chatId,
workflowId: c?.workflowId,
executionId: (c as any)?.executionId,
label: c?.label,
}))
: undefined,
})
logger.error(
appendCopilotLogContext('Received chat POST', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
workflowId,
hasContexts: Array.isArray(normalizedContexts),
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
contextsPreview: Array.isArray(normalizedContexts)
? normalizedContexts.map((c: any) => ({
kind: c?.kind,
chatId: c?.chatId,
workflowId: c?.workflowId,
executionId: (c as any)?.executionId,
label: c?.label,
}))
: undefined,
}
)
} catch {}
let currentChat: any = null
@@ -245,22 +251,40 @@ export async function POST(req: NextRequest) {
actualChatId
)
agentContexts = processed
reqLogger.info('Contexts processed for request', {
processedCount: agentContexts.length,
kinds: agentContexts.map((c) => c.type),
lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
})
logger.error(
appendCopilotLogContext('Contexts processed for request', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
processedCount: agentContexts.length,
kinds: agentContexts.map((c) => c.type),
lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
}
)
if (
Array.isArray(normalizedContexts) &&
normalizedContexts.length > 0 &&
agentContexts.length === 0
) {
reqLogger.warn(
'Contexts provided but none processed. Check executionId for logs contexts.'
logger.warn(
appendCopilotLogContext(
'Contexts provided but none processed. Check executionId for logs contexts.',
{
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}
)
)
}
} catch (e) {
reqLogger.error('Failed to process contexts', e)
logger.error(
appendCopilotLogContext('Failed to process contexts', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
e
)
}
}
@@ -289,7 +313,13 @@ export async function POST(req: NextRequest) {
if (result.status === 'fulfilled' && result.value) {
agentContexts.push(result.value)
} else if (result.status === 'rejected') {
reqLogger.error('Failed to resolve resource attachment', result.reason)
logger.error(
appendCopilotLogContext('Failed to resolve resource attachment', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
result.reason
)
}
}
}
@@ -328,20 +358,26 @@ export async function POST(req: NextRequest) {
)
try {
reqLogger.info('About to call Sim Agent', {
hasContext: agentContexts.length > 0,
contextCount: agentContexts.length,
hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
messageLength: message.length,
mode: effectiveMode,
hasTools: Array.isArray(requestPayload.tools),
toolCount: Array.isArray(requestPayload.tools) ? requestPayload.tools.length : 0,
hasBaseTools: Array.isArray(requestPayload.baseTools),
baseToolCount: Array.isArray(requestPayload.baseTools)
? requestPayload.baseTools.length
: 0,
hasCredentials: !!requestPayload.credentials,
})
logger.error(
appendCopilotLogContext('About to call Sim Agent', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
hasContext: agentContexts.length > 0,
contextCount: agentContexts.length,
hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
messageLength: message.length,
mode: effectiveMode,
hasTools: Array.isArray(requestPayload.tools),
toolCount: Array.isArray(requestPayload.tools) ? requestPayload.tools.length : 0,
hasBaseTools: Array.isArray(requestPayload.baseTools),
baseToolCount: Array.isArray(requestPayload.baseTools)
? requestPayload.baseTools.length
: 0,
hasCredentials: !!requestPayload.credentials,
}
)
} catch {}
if (stream && actualChatId) {
@@ -485,10 +521,16 @@ export async function POST(req: NextRequest) {
.where(eq(copilotChats.id, actualChatId))
}
} catch (error) {
reqLogger.error('Failed to persist chat messages', {
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
})
logger.error(
appendCopilotLogContext('Failed to persist chat messages', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
}
)
}
},
},
@@ -530,13 +572,19 @@ export async function POST(req: NextRequest) {
provider: typeof requestPayload?.provider === 'string' ? requestPayload.provider : undefined,
}
reqLogger.info('Non-streaming response from orchestrator', {
hasContent: !!responseData.content,
contentLength: responseData.content?.length || 0,
model: responseData.model,
provider: responseData.provider,
toolCallsCount: responseData.toolCalls?.length || 0,
})
logger.error(
appendCopilotLogContext('Non-streaming response from orchestrator', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
hasContent: !!responseData.content,
contentLength: responseData.content?.length || 0,
model: responseData.model,
provider: responseData.provider,
toolCallsCount: responseData.toolCalls?.length || 0,
}
)
// Save messages if we have a chat
if (currentChat && responseData.content) {
@@ -569,7 +617,12 @@ export async function POST(req: NextRequest) {
// Start title generation in parallel if this is first message (non-streaming)
if (actualChatId && !currentChat.title && conversationHistory.length === 0) {
reqLogger.info('Starting title generation for non-streaming response')
logger.error(
appendCopilotLogContext('Starting title generation for non-streaming response', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
})
)
requestChatTitle({ message, model: selectedModel, provider, messageId: userMessageIdToUse })
.then(async (title) => {
if (title) {
@@ -580,11 +633,22 @@ export async function POST(req: NextRequest) {
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId!))
reqLogger.info(`Generated and saved title: ${title}`)
logger.error(
appendCopilotLogContext(`Generated and saved title: ${title}`, {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
})
)
}
})
.catch((error) => {
reqLogger.error('Title generation failed', error)
logger.error(
appendCopilotLogContext('Title generation failed', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
error
)
})
}
@@ -598,11 +662,17 @@ export async function POST(req: NextRequest) {
.where(eq(copilotChats.id, actualChatId!))
}
reqLogger.info('Returning non-streaming response', {
duration: tracker.getDuration(),
chatId: actualChatId,
responseLength: responseData.content?.length || 0,
})
logger.error(
appendCopilotLogContext('Returning non-streaming response', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
}),
{
duration: tracker.getDuration(),
chatId: actualChatId,
responseLength: responseData.content?.length || 0,
}
)
return NextResponse.json({
success: true,
@@ -626,25 +696,33 @@ export async function POST(req: NextRequest) {
const duration = tracker.getDuration()
if (error instanceof z.ZodError) {
logger
.withMetadata({ requestId: tracker.requestId, messageId: pendingChatStreamID ?? undefined })
.error('Validation error', {
logger.error(
appendCopilotLogContext('Validation error', {
requestId: tracker.requestId,
messageId: pendingChatStreamID ?? undefined,
}),
{
duration,
errors: error.errors,
})
}
)
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger
.withMetadata({ requestId: tracker.requestId, messageId: pendingChatStreamID ?? undefined })
.error('Error handling copilot chat', {
logger.error(
appendCopilotLogContext('Error handling copilot chat', {
requestId: tracker.requestId,
messageId: pendingChatStreamID ?? undefined,
}),
{
duration,
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
})
}
)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
@@ -689,13 +767,16 @@ export async function GET(req: NextRequest) {
status: meta?.status || 'unknown',
}
} catch (err) {
logger
.withMetadata({ messageId: chat.conversationId || undefined })
.warn('Failed to read stream snapshot for chat', {
logger.warn(
appendCopilotLogContext('Failed to read stream snapshot for chat', {
messageId: chat.conversationId || undefined,
}),
{
chatId,
conversationId: chat.conversationId,
error: err instanceof Error ? err.message : String(err),
})
}
)
}
}
@@ -714,9 +795,11 @@ export async function GET(req: NextRequest) {
...(streamSnapshot ? { streamSnapshot } : {}),
}
logger
.withMetadata({ messageId: chat.conversationId || undefined })
.info(`Retrieved chat ${chatId}`)
logger.error(
appendCopilotLogContext(`Retrieved chat ${chatId}`, {
messageId: chat.conversationId || undefined,
})
)
return NextResponse.json({ success: true, chat: transformedChat })
}

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import {
getStreamMeta,
readStreamEvents,
@@ -35,21 +36,24 @@ export async function GET(request: NextRequest) {
const toParam = url.searchParams.get('to')
const toEventId = toParam ? Number(toParam) : undefined
const reqLogger = logger.withMetadata({ messageId: streamId || undefined })
reqLogger.info('[Resume] Received resume request', {
streamId: streamId || undefined,
fromEventId,
toEventId,
batchMode,
})
logger.error(
appendCopilotLogContext('[Resume] Received resume request', {
messageId: streamId || undefined,
}),
{
streamId: streamId || undefined,
fromEventId,
toEventId,
batchMode,
}
)
if (!streamId) {
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
}
const meta = (await getStreamMeta(streamId)) as StreamMeta | null
reqLogger.info('[Resume] Stream lookup', {
logger.error(appendCopilotLogContext('[Resume] Stream lookup', { messageId: streamId }), {
streamId,
fromEventId,
toEventId,
@@ -68,7 +72,7 @@ export async function GET(request: NextRequest) {
if (batchMode) {
const events = await readStreamEvents(streamId, fromEventId)
const filteredEvents = toEventId ? events.filter((e) => e.eventId <= toEventId) : events
reqLogger.info('[Resume] Batch response', {
logger.error(appendCopilotLogContext('[Resume] Batch response', { messageId: streamId }), {
streamId,
fromEventId,
toEventId,
@@ -120,11 +124,14 @@ export async function GET(request: NextRequest) {
const flushEvents = async () => {
const events = await readStreamEvents(streamId, lastEventId)
if (events.length > 0) {
reqLogger.info('[Resume] Flushing events', {
streamId,
fromEventId: lastEventId,
eventCount: events.length,
})
logger.error(
appendCopilotLogContext('[Resume] Flushing events', { messageId: streamId }),
{
streamId,
fromEventId: lastEventId,
eventCount: events.length,
}
)
}
for (const entry of events) {
lastEventId = entry.eventId
@@ -171,7 +178,7 @@ export async function GET(request: NextRequest) {
}
} catch (error) {
if (!controllerClosed && !request.signal.aborted) {
reqLogger.warn('Stream replay failed', {
logger.warn(appendCopilotLogContext('Stream replay failed', { messageId: streamId }), {
streamId,
error: error instanceof Error ? error.message : String(error),
})

View File

@@ -12,7 +12,6 @@ const {
mockReturning,
mockSelect,
mockFrom,
mockWhere,
mockAuthenticate,
mockCreateUnauthorizedResponse,
mockCreateBadRequestResponse,
@@ -24,7 +23,6 @@ const {
mockReturning: vi.fn(),
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockAuthenticate: vi.fn(),
mockCreateUnauthorizedResponse: vi.fn(),
mockCreateBadRequestResponse: vi.fn(),
@@ -83,8 +81,7 @@ describe('Copilot Feedback API Route', () => {
mockValues.mockReturnValue({ returning: mockReturning })
mockReturning.mockResolvedValue([])
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockResolvedValue([])
mockFrom.mockResolvedValue([])
mockCreateRequestTracker.mockReturnValue({
requestId: 'test-request-id',
@@ -389,7 +386,7 @@ edges:
isAuthenticated: true,
})
mockWhere.mockResolvedValueOnce([])
mockFrom.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
@@ -400,7 +397,7 @@ edges:
expect(responseData.feedback).toEqual([])
})
it('should only return feedback records for the authenticated user', async () => {
it('should return all feedback records', async () => {
mockAuthenticate.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
@@ -418,8 +415,19 @@ edges:
workflowYaml: null,
createdAt: new Date('2024-01-01'),
},
{
feedbackId: 'feedback-2',
userId: 'user-456',
chatId: 'chat-2',
userQuery: 'Query 2',
agentResponse: 'Response 2',
isPositive: false,
feedback: 'Not helpful',
workflowYaml: 'yaml: content',
createdAt: new Date('2024-01-02'),
},
]
mockWhere.mockResolvedValueOnce(mockFeedback)
mockFrom.mockResolvedValueOnce(mockFeedback)
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
@@ -427,14 +435,9 @@ edges:
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.feedback).toHaveLength(1)
expect(responseData.feedback).toHaveLength(2)
expect(responseData.feedback[0].feedbackId).toBe('feedback-1')
expect(responseData.feedback[0].userId).toBe('user-123')
// Verify the where clause was called with the authenticated user's ID
const { eq } = await import('drizzle-orm')
expect(mockWhere).toHaveBeenCalled()
expect(eq).toHaveBeenCalledWith('userId', 'user-123')
expect(responseData.feedback[1].feedbackId).toBe('feedback-2')
})
it('should handle database errors gracefully', async () => {
@@ -443,7 +446,7 @@ edges:
isAuthenticated: true,
})
mockWhere.mockRejectedValueOnce(new Error('Database connection failed'))
mockFrom.mockRejectedValueOnce(new Error('Database connection failed'))
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
@@ -459,7 +462,7 @@ edges:
isAuthenticated: true,
})
mockWhere.mockResolvedValueOnce([])
mockFrom.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)

View File

@@ -1,7 +1,6 @@
import { db } from '@sim/db'
import { copilotFeedback } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
@@ -110,7 +109,7 @@ export async function POST(req: NextRequest) {
/**
* GET /api/copilot/feedback
* Get feedback records for the authenticated user
* Get all feedback records (for analytics)
*/
export async function GET(req: NextRequest) {
const tracker = createRequestTracker()
@@ -124,7 +123,7 @@ export async function GET(req: NextRequest) {
return createUnauthorizedResponse()
}
// Get feedback records for the authenticated user only
// Get all feedback records
const feedbackRecords = await db
.select({
feedbackId: copilotFeedback.feedbackId,
@@ -138,7 +137,6 @@ export async function GET(req: NextRequest) {
createdAt: copilotFeedback.createdAt,
})
.from(copilotFeedback)
.where(eq(copilotFeedback.userId, authenticatedUserId))
logger.info(`[${tracker.requestId}] Retrieved ${feedbackRecords.length} feedback records`)

View File

@@ -1,10 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
authenticateCopilotRequestSessionOnly,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
const logger = createLogger('CopilotTrainingExamplesAPI')
@@ -20,11 +16,6 @@ const TrainingExampleSchema = z.object({
})
export async function POST(request: NextRequest) {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const baseUrl = env.AGENT_INDEXER_URL
if (!baseUrl) {
logger.error('Missing AGENT_INDEXER_URL environment variable')

View File

@@ -1,10 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
authenticateCopilotRequestSessionOnly,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
const logger = createLogger('CopilotTrainingAPI')
@@ -26,11 +22,6 @@ const TrainingDataSchema = z.object({
})
export async function POST(request: NextRequest) {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
try {
const baseUrl = env.AGENT_INDEXER_URL
if (!baseUrl) {

View File

@@ -100,7 +100,7 @@ const emailTemplates = {
trigger: 'api',
duration: '2.3s',
cost: '$0.0042',
logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=exec_abc123',
logUrl: 'https://sim.ai/workspace/ws_123/logs?search=exec_abc123',
}),
'workflow-notification-error': () =>
renderWorkflowNotificationEmail({
@@ -109,7 +109,7 @@ const emailTemplates = {
trigger: 'webhook',
duration: '1.1s',
cost: '$0.0021',
logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=exec_abc123',
logUrl: 'https://sim.ai/workspace/ws_123/logs?search=exec_abc123',
}),
'workflow-notification-alert': () =>
renderWorkflowNotificationEmail({
@@ -118,7 +118,7 @@ const emailTemplates = {
trigger: 'schedule',
duration: '45.2s',
cost: '$0.0156',
logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=exec_abc123',
logUrl: 'https://sim.ai/workspace/ws_123/logs?search=exec_abc123',
alertReason: '3 consecutive failures detected',
}),
'workflow-notification-full': () =>
@@ -128,7 +128,7 @@ const emailTemplates = {
trigger: 'api',
duration: '12.5s',
cost: '$0.0234',
logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=exec_abc123',
logUrl: 'https://sim.ai/workspace/ws_123/logs?search=exec_abc123',
finalOutput: { processed: 150, skipped: 3, status: 'completed' },
rateLimits: {
sync: { requestsPerMinute: 60, remaining: 45 },

View File

@@ -6,14 +6,7 @@
import { auditMock, createMockRequest, type MockUser } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetSession,
mockGetUserEntityPermissions,
mockLogger,
mockDbRef,
mockPerformDeleteFolder,
mockCheckForCircularReference,
} = vi.hoisted(() => {
const { mockGetSession, mockGetUserEntityPermissions, mockLogger, mockDbRef } = vi.hoisted(() => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
@@ -28,8 +21,6 @@ const {
mockGetUserEntityPermissions: vi.fn(),
mockLogger: logger,
mockDbRef: { current: null as any },
mockPerformDeleteFolder: vi.fn(),
mockCheckForCircularReference: vi.fn(),
}
})
@@ -48,12 +39,6 @@ vi.mock('@sim/db', () => ({
return mockDbRef.current
},
}))
vi.mock('@/lib/workflows/orchestration', () => ({
performDeleteFolder: mockPerformDeleteFolder,
}))
vi.mock('@/lib/workflows/utils', () => ({
checkForCircularReference: mockCheckForCircularReference,
}))
import { DELETE, PUT } from '@/app/api/folders/[id]/route'
@@ -159,11 +144,6 @@ describe('Individual Folder API Route', () => {
mockGetUserEntityPermissions.mockResolvedValue('admin')
mockDbRef.current = createFolderDbMock()
mockPerformDeleteFolder.mockResolvedValue({
success: true,
deletedItems: { folders: 1, workflows: 0 },
})
mockCheckForCircularReference.mockResolvedValue(false)
})
describe('PUT /api/folders/[id]', () => {
@@ -389,16 +369,12 @@ describe('Individual Folder API Route', () => {
it('should prevent circular references when updating parent', async () => {
mockAuthenticatedUser()
mockDbRef.current = createFolderDbMock({
folderLookupResult: {
id: 'folder-3',
parentId: null,
name: 'Folder 3',
workspaceId: 'workspace-123',
},
})
const circularCheckResults = [{ parentId: 'folder-2' }, { parentId: 'folder-3' }]
mockCheckForCircularReference.mockResolvedValue(true)
mockDbRef.current = createFolderDbMock({
folderLookupResult: { id: 'folder-3', parentId: null, name: 'Folder 3' },
circularCheckResults,
})
const req = createMockRequest('PUT', {
name: 'Updated Folder 3',
@@ -412,7 +388,6 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('error', 'Cannot create circular folder reference')
expect(mockCheckForCircularReference).toHaveBeenCalledWith('folder-3', 'folder-1')
})
})
@@ -434,12 +409,6 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('success', true)
expect(data).toHaveProperty('deletedItems')
expect(mockPerformDeleteFolder).toHaveBeenCalledWith({
folderId: 'folder-1',
workspaceId: 'workspace-123',
userId: TEST_USER.id,
folderName: 'Test Folder',
})
})
it('should return 401 for unauthenticated delete requests', async () => {
@@ -503,7 +472,6 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('success', true)
expect(mockPerformDeleteFolder).toHaveBeenCalled()
})
it('should handle database errors during deletion', async () => {

View File

@@ -1,12 +1,12 @@
import { db } from '@sim/db'
import { workflowFolder } from '@sim/db/schema'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { performDeleteFolder } from '@/lib/workflows/orchestration'
import { checkForCircularReference } from '@/lib/workflows/utils'
import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FoldersIDAPI')
@@ -130,6 +130,7 @@ export async function DELETE(
return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
}
// Check if user has admin permissions for the workspace (admin-only for deletions)
const workspacePermission = await getUserEntityPermissions(
session.user.id,
'workspace',
@@ -143,25 +144,170 @@ export async function DELETE(
)
}
const result = await performDeleteFolder({
folderId: id,
workspaceId: existingFolder.workspaceId,
userId: session.user.id,
folderName: existingFolder.name,
// Check if deleting this folder would delete the last workflow(s) in the workspace
const workflowsInFolder = await countWorkflowsInFolderRecursively(
id,
existingFolder.workspaceId
)
const totalWorkflowsInWorkspace = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, existingFolder.workspaceId), isNull(workflow.archivedAt)))
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
return NextResponse.json(
{ error: 'Cannot delete folder containing the only workflow(s) in the workspace' },
{ status: 400 }
)
}
// Recursively delete folder and all its contents
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
logger.info('Deleted folder and all contents:', {
id,
deletionStats,
})
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return NextResponse.json({ error: result.error }, { status })
}
recordAudit({
workspaceId: existingFolder.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FOLDER_DELETED,
resourceType: AuditResourceType.FOLDER,
resourceId: id,
resourceName: existingFolder.name,
description: `Deleted folder "${existingFolder.name}"`,
metadata: {
affected: {
workflows: deletionStats.workflows,
subfolders: deletionStats.folders - 1,
},
},
request,
})
return NextResponse.json({
success: true,
deletedItems: result.deletedItems,
deletedItems: deletionStats,
})
} catch (error) {
logger.error('Error deleting folder:', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// Helper function to recursively delete a folder and all its contents
async function deleteFolderRecursively(
folderId: string,
workspaceId: string
): Promise<{ folders: number; workflows: number }> {
const stats = { folders: 0, workflows: 0 }
// Get all child folders first (workspace-scoped, not user-scoped)
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
// Recursively delete child folders
for (const childFolder of childFolders) {
const childStats = await deleteFolderRecursively(childFolder.id, workspaceId)
stats.folders += childStats.folders
stats.workflows += childStats.workflows
}
// Delete all workflows in this folder (workspace-scoped, not user-scoped)
// The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
)
if (workflowsInFolder.length > 0) {
await archiveWorkflowsByIdsInWorkspace(
workspaceId,
workflowsInFolder.map((entry) => entry.id),
{ requestId: `folder-${folderId}` }
)
stats.workflows += workflowsInFolder.length
}
// Delete this folder
await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId))
stats.folders += 1
return stats
}
/**
* Counts the number of workflows in a folder and all its subfolders recursively.
*/
async function countWorkflowsInFolderRecursively(
folderId: string,
workspaceId: string
): Promise<number> {
let count = 0
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
)
count += workflowsInFolder.length
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
for (const childFolder of childFolders) {
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
}
return count
}
// Helper function to check for circular references
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
let currentParentId: string | null = parentId
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
return true // Circular reference detected
}
if (currentParentId === folderId) {
return true // Would create a cycle
}
visited.add(currentParentId)
// Get the parent of the current parent
const parent: { parentId: string | null } | undefined = await db
.select({ parentId: workflowFolder.parentId })
.from(workflowFolder)
.where(eq(workflowFolder.id, currentParentId))
.then((rows) => rows[0])
currentParentId = parent?.parentId || null
}
return false
}

View File

@@ -26,14 +26,6 @@ vi.mock('@/lib/execution/e2b', () => ({
executeInE2B: mockExecuteInE2B,
}))
vi.mock('@/lib/core/config/feature-flags', () => ({
isHosted: false,
isE2bEnabled: false,
isProd: false,
isDev: false,
isTest: true,
}))
import { validateProxyUrl } from '@/lib/core/security/input-validation'
import { POST } from '@/app/api/function/execute/route'

View File

@@ -237,7 +237,7 @@ describe('Knowledge Connector By ID API Route', () => {
.mockReturnValueOnce(mockDbChain)
.mockResolvedValueOnce([{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }])
.mockReturnValueOnce(mockDbChain)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }])
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'conn-456' }])
const req = createMockRequest('DELETE')

View File

@@ -292,10 +292,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const { searchParams } = new URL(request.url)
const deleteDocuments = searchParams.get('deleteDocuments') === 'true'
const { deletedDocs, docCount } = await db.transaction(async (tx) => {
const connectorDocuments = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`)
const docs = await tx
@@ -309,12 +306,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
)
)
if (deleteDocuments) {
const documentIds = docs.map((doc) => doc.id)
if (documentIds.length > 0) {
await tx.delete(embedding).where(inArray(embedding.documentId, documentIds))
await tx.delete(document).where(inArray(document.id, documentIds))
}
const documentIds = docs.map((doc) => doc.id)
if (documentIds.length > 0) {
await tx.delete(embedding).where(inArray(embedding.documentId, documentIds))
await tx.delete(document).where(inArray(document.id, documentIds))
}
const deletedConnectors = await tx
@@ -333,23 +328,16 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
throw new Error('Connector not found')
}
return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length }
return docs
})
if (deleteDocuments) {
await Promise.all([
deletedDocs.length > 0
? deleteDocumentStorageFiles(deletedDocs, requestId)
: Promise.resolve(),
cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
}),
])
}
await deleteDocumentStorageFiles(connectorDocuments, requestId)
logger.info(
`[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}`
)
await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
})
logger.info(`[${requestId}] Hard-deleted connector ${connectorId} and its documents`)
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
@@ -361,11 +349,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
resourceId: connectorId,
resourceName: existingConnector[0].connectorType,
description: `Deleted connector from knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: {
knowledgeBaseId,
documentsDeleted: deleteDocuments ? docCount : 0,
documentsKept: deleteDocuments ? 0 : docCount,
},
metadata: { knowledgeBaseId, documentsDeleted: connectorDocuments.length },
request,
})

View File

@@ -15,8 +15,6 @@ const GetChunksQuerySchema = z.object({
enabled: z.enum(['true', 'false', 'all']).optional().default('all'),
limit: z.coerce.number().min(1).max(100).optional().default(50),
offset: z.coerce.number().min(0).optional().default(0),
sortBy: z.enum(['chunkIndex', 'tokenCount', 'enabled']).optional().default('chunkIndex'),
sortOrder: z.enum(['asc', 'desc']).optional().default('asc'),
})
const CreateChunkSchema = z.object({
@@ -90,8 +88,6 @@ export async function GET(
enabled: searchParams.get('enabled') || undefined,
limit: searchParams.get('limit') || undefined,
offset: searchParams.get('offset') || undefined,
sortBy: searchParams.get('sortBy') || undefined,
sortOrder: searchParams.get('sortOrder') || undefined,
})
const result = await queryChunks(documentId, queryParams, requestId)

View File

@@ -12,6 +12,7 @@ import {
createSSEStream,
SSE_RESPONSE_HEADERS,
} from '@/lib/copilot/chat-streaming'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents'
import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers'
@@ -111,22 +112,27 @@ export async function POST(req: NextRequest) {
const userMessageId = providedMessageId || crypto.randomUUID()
userMessageIdForLogs = userMessageId
const reqLogger = logger.withMetadata({
requestId: tracker.requestId,
messageId: userMessageId,
})
reqLogger.info('Received mothership chat start request', {
workspaceId,
chatId,
createNewChat,
hasContexts: Array.isArray(contexts) && contexts.length > 0,
contextsCount: Array.isArray(contexts) ? contexts.length : 0,
hasResourceAttachments: Array.isArray(resourceAttachments) && resourceAttachments.length > 0,
resourceAttachmentCount: Array.isArray(resourceAttachments) ? resourceAttachments.length : 0,
hasFileAttachments: Array.isArray(fileAttachments) && fileAttachments.length > 0,
fileAttachmentCount: Array.isArray(fileAttachments) ? fileAttachments.length : 0,
})
logger.error(
appendCopilotLogContext('Received mothership chat start request', {
requestId: tracker.requestId,
messageId: userMessageId,
}),
{
workspaceId,
chatId,
createNewChat,
hasContexts: Array.isArray(contexts) && contexts.length > 0,
contextsCount: Array.isArray(contexts) ? contexts.length : 0,
hasResourceAttachments:
Array.isArray(resourceAttachments) && resourceAttachments.length > 0,
resourceAttachmentCount: Array.isArray(resourceAttachments)
? resourceAttachments.length
: 0,
hasFileAttachments: Array.isArray(fileAttachments) && fileAttachments.length > 0,
fileAttachmentCount: Array.isArray(fileAttachments) ? fileAttachments.length : 0,
}
)
try {
await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId)
@@ -168,7 +174,13 @@ export async function POST(req: NextRequest) {
actualChatId
)
} catch (e) {
reqLogger.error('Failed to process contexts', e)
logger.error(
appendCopilotLogContext('Failed to process contexts', {
requestId: tracker.requestId,
messageId: userMessageId,
}),
e
)
}
}
@@ -193,7 +205,13 @@ export async function POST(req: NextRequest) {
if (result.status === 'fulfilled' && result.value) {
agentContexts.push(result.value)
} else if (result.status === 'rejected') {
reqLogger.error('Failed to resolve resource attachment', result.reason)
logger.error(
appendCopilotLogContext('Failed to resolve resource attachment', {
requestId: tracker.requestId,
messageId: userMessageId,
}),
result.reason
)
}
}
}
@@ -381,10 +399,16 @@ export async function POST(req: NextRequest) {
})
}
} catch (error) {
reqLogger.error('Failed to persist chat messages', {
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
})
logger.error(
appendCopilotLogContext('Failed to persist chat messages', {
requestId: tracker.requestId,
messageId: userMessageId,
}),
{
chatId: actualChatId,
error: error instanceof Error ? error.message : 'Unknown error',
}
)
}
},
},
@@ -399,11 +423,15 @@ export async function POST(req: NextRequest) {
)
}
logger
.withMetadata({ requestId: tracker.requestId, messageId: userMessageIdForLogs })
.error('Error handling mothership chat', {
logger.error(
appendCopilotLogContext('Error handling mothership chat', {
requestId: tracker.requestId,
messageId: userMessageIdForLogs,
}),
{
error: error instanceof Error ? error.message : 'Unknown error',
})
}
)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },

View File

@@ -5,7 +5,6 @@ import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { releasePendingChatStream } from '@/lib/copilot/chat-streaming'
import { taskPubSub } from '@/lib/copilot/task-events'
const logger = createLogger('MothershipChatStopAPI')
@@ -59,8 +58,6 @@ export async function POST(req: NextRequest) {
const { chatId, streamId, content, contentBlocks } = StopSchema.parse(await req.json())
await releasePendingChatStream(chatId, streamId)
const setClause: Record<string, unknown> = {
conversationId: null,
updatedAt: new Date(),

View File

@@ -5,6 +5,7 @@ import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
import {
authenticateCopilotRequestSessionOnly,
@@ -62,13 +63,16 @@ export async function GET(
status: meta?.status || 'unknown',
}
} catch (error) {
logger
.withMetadata({ messageId: chat.conversationId || undefined })
.warn('Failed to read stream snapshot for mothership chat', {
logger.warn(
appendCopilotLogContext('Failed to read stream snapshot for mothership chat', {
messageId: chat.conversationId || undefined,
}),
{
chatId,
conversationId: chat.conversationId,
error: error instanceof Error ? error.message : String(error),
})
}
)
}
}

View File

@@ -4,6 +4,7 @@ import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { generateWorkspaceContext } from '@/lib/copilot/workspace-context'
import {
@@ -52,7 +53,6 @@ export async function POST(req: NextRequest) {
const effectiveChatId = chatId || crypto.randomUUID()
messageId = crypto.randomUUID()
const reqLogger = logger.withMetadata({ messageId })
const [workspaceContext, integrationTools, userPermission] = await Promise.all([
generateWorkspaceContext(workspaceId, userId),
buildIntegrationToolSchemas(userId, messageId),
@@ -96,7 +96,7 @@ export async function POST(req: NextRequest) {
})
if (!result.success) {
reqLogger.error('Mothership execute failed', {
logger.error(appendCopilotLogContext('Mothership execute failed', { messageId }), {
error: result.error,
errors: result.errors,
})
@@ -135,7 +135,7 @@ export async function POST(req: NextRequest) {
)
}
logger.withMetadata({ messageId }).error('Mothership execute error', {
logger.error(appendCopilotLogContext('Mothership execute error', { messageId }), {
error: error instanceof Error ? error.message : 'Unknown error',
})

View File

@@ -61,21 +61,6 @@ export async function GET(
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
// Verify caller is either an org member or the invitee
const isInvitee = session.user.email?.toLowerCase() === orgInvitation.email.toLowerCase()
if (!isInvitee) {
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const org = await db
.select()
.from(organization)

View File

@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations'
@@ -97,18 +96,6 @@ export async function POST(req: NextRequest) {
requestId,
})
for (const skill of resultSkills) {
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.SKILL_CREATED,
resourceType: AuditResourceType.SKILL,
resourceId: skill.id,
resourceName: skill.name,
description: `Created/updated skill "${skill.name}"`,
})
}
return NextResponse.json({ success: true, data: resultSkills })
} catch (validationError) {
if (validationError instanceof z.ZodError) {
@@ -171,15 +158,6 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
}
recordAudit({
workspaceId,
actorId: authResult.userId,
action: AuditAction.SKILL_DELETED,
resourceType: AuditResourceType.SKILL,
resourceId: skillId,
description: `Deleted skill`,
})
logger.info(`[${requestId}] Deleted skill: ${skillId}`)
return NextResponse.json({ success: true })
} catch (error) {

View File

@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
@@ -167,18 +166,6 @@ export async function POST(req: NextRequest) {
requestId,
})
for (const tool of resultTools) {
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.CUSTOM_TOOL_CREATED,
resourceType: AuditResourceType.CUSTOM_TOOL,
resourceId: tool.id,
resourceName: tool.title,
description: `Created/updated custom tool "${tool.title}"`,
})
}
return NextResponse.json({ success: true, data: resultTools })
} catch (validationError) {
if (validationError instanceof z.ZodError) {
@@ -278,15 +265,6 @@ export async function DELETE(request: NextRequest) {
// Delete the tool
await db.delete(customTools).where(eq(customTools.id, toolId))
recordAudit({
workspaceId: tool.workspaceId || undefined,
actorId: userId,
action: AuditAction.CUSTOM_TOOL_DELETED,
resourceType: AuditResourceType.CUSTOM_TOOL,
resourceId: toolId,
description: `Deleted custom tool`,
})
logger.info(`[${requestId}] Deleted tool: ${toolId}`)
return NextResponse.json({ success: true })
} catch (error) {

View File

@@ -1,166 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
import {
downloadWorkspaceFile,
getWorkspaceFileByName,
updateWorkspaceFileContent,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('FileManageAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ success: false, error: auth.error }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const userId = auth.userId || searchParams.get('userId')
if (!userId) {
return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 })
}
let body: Record<string, unknown>
try {
body = await request.json()
} catch {
return NextResponse.json({ success: false, error: 'Invalid JSON body' }, { status: 400 })
}
const workspaceId = (body.workspaceId as string) || searchParams.get('workspaceId')
if (!workspaceId) {
return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 })
}
const operation = body.operation as string
try {
switch (operation) {
case 'write': {
const fileName = body.fileName as string | undefined
const content = body.content as string | undefined
const contentType = body.contentType as string | undefined
if (!fileName) {
return NextResponse.json(
{ success: false, error: 'fileName is required for write operation' },
{ status: 400 }
)
}
if (!content && content !== '') {
return NextResponse.json(
{ success: false, error: 'content is required for write operation' },
{ status: 400 }
)
}
const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName))
const fileBuffer = Buffer.from(content ?? '', 'utf-8')
const result = await uploadWorkspaceFile(
workspaceId,
userId,
fileBuffer,
fileName,
mimeType
)
logger.info('File created', {
fileId: result.id,
name: fileName,
size: fileBuffer.length,
})
return NextResponse.json({
success: true,
data: {
id: result.id,
name: result.name,
size: fileBuffer.length,
url: ensureAbsoluteUrl(result.url),
},
})
}
case 'append': {
const fileName = body.fileName as string | undefined
const content = body.content as string | undefined
if (!fileName) {
return NextResponse.json(
{ success: false, error: 'fileName is required for append operation' },
{ status: 400 }
)
}
if (!content && content !== '') {
return NextResponse.json(
{ success: false, error: 'content is required for append operation' },
{ status: 400 }
)
}
const existing = await getWorkspaceFileByName(workspaceId, fileName)
if (!existing) {
return NextResponse.json(
{ success: false, error: `File not found: "${fileName}"` },
{ status: 404 }
)
}
const lockKey = `file-append:${workspaceId}:${existing.id}`
const lockValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`
const acquired = await acquireLock(lockKey, lockValue, 30)
if (!acquired) {
return NextResponse.json(
{ success: false, error: 'File is busy, please retry' },
{ status: 409 }
)
}
try {
const existingBuffer = await downloadWorkspaceFile(existing)
const finalContent = existingBuffer.toString('utf-8') + content
const fileBuffer = Buffer.from(finalContent, 'utf-8')
await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer)
logger.info('File appended', {
fileId: existing.id,
name: existing.name,
size: fileBuffer.length,
})
return NextResponse.json({
success: true,
data: {
id: existing.id,
name: existing.name,
size: fileBuffer.length,
url: ensureAbsoluteUrl(existing.path),
},
})
} finally {
await releaseLock(lockKey, lockValue)
}
}
default:
return NextResponse.json(
{ success: false, error: `Unknown operation: ${operation}. Supported: write, append` },
{ status: 400 }
)
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('File operation failed', { operation, error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger'
import { ImapFlow } from 'imapflow'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
const logger = createLogger('ImapMailboxesAPI')
@@ -10,6 +9,7 @@ interface ImapMailboxRequest {
host: string
port: number
secure: boolean
rejectUnauthorized: boolean
username: string
password: string
}
@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as ImapMailboxRequest
const { host, port, secure, username, password } = body
const { host, port, secure, rejectUnauthorized, username, password } = body
if (!host || !username || !password) {
return NextResponse.json(
@@ -31,14 +31,8 @@ export async function POST(request: NextRequest) {
)
}
const hostValidation = await validateDatabaseHost(host, 'host')
if (!hostValidation.isValid) {
return NextResponse.json({ success: false, message: hostValidation.error }, { status: 400 })
}
const client = new ImapFlow({
host: hostValidation.resolvedIP!,
servername: host,
host,
port: port || 993,
secure: secure ?? true,
auth: {
@@ -46,7 +40,7 @@ export async function POST(request: NextRequest) {
pass: password,
},
tls: {
rejectUnauthorized: true,
rejectUnauthorized: rejectUnauthorized ?? true,
},
logger: false,
})
@@ -85,12 +79,21 @@ export async function POST(request: NextRequest) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Error fetching IMAP mailboxes:', errorMessage)
let userMessage = 'Failed to connect to IMAP server. Please check your connection settings.'
let userMessage = 'Failed to connect to IMAP server'
if (
errorMessage.includes('AUTHENTICATIONFAILED') ||
errorMessage.includes('Invalid credentials')
) {
userMessage = 'Invalid username or password. For Gmail, use an App Password.'
} else if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) {
userMessage = 'Could not find IMAP server. Please check the hostname.'
} else if (errorMessage.includes('ECONNREFUSED')) {
userMessage = 'Connection refused. Please check the port and SSL settings.'
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL')) {
userMessage =
'TLS/SSL error. Try disabling "Verify TLS Certificate" for self-signed certificates.'
} else if (errorMessage.includes('timeout')) {
userMessage = 'Connection timed out. Please check your network and server settings.'
}
return NextResponse.json({ success: false, message: userMessage }, { status: 500 })

View File

@@ -1,5 +1,4 @@
import { type Attributes, Client, type ConnectConfig, type SFTPWrapper } from 'ssh2'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
const S_IFMT = 0o170000
const S_IFDIR = 0o040000
@@ -92,23 +91,16 @@ function formatSftpError(err: Error, config: { host: string; port: number }): Er
* Creates an SSH connection for SFTP using the provided configuration.
* Uses ssh2 library defaults which align with OpenSSH standards.
*/
export async function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
const host = config.host
if (!host || host.trim() === '') {
throw new Error('Host is required. Please provide a valid hostname or IP address.')
}
const hostValidation = await validateDatabaseHost(host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
const resolvedHost = hostValidation.resolvedIP ?? host.trim()
export function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
return new Promise((resolve, reject) => {
const client = new Client()
const port = config.port || 22
const host = config.host
if (!host || host.trim() === '') {
reject(new Error('Host is required. Please provide a valid hostname or IP address.'))
return
}
const hasPassword = config.password && config.password.trim() !== ''
const hasPrivateKey = config.privateKey && config.privateKey.trim() !== ''
@@ -119,7 +111,7 @@ export async function createSftpConnection(config: SftpConnectionConfig): Promis
}
const connectConfig: ConnectConfig = {
host: resolvedHost,
host: host.trim(),
port,
username: config.username,
}

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
@@ -57,15 +56,6 @@ export async function POST(request: NextRequest) {
const body = await request.json()
const validatedData = SmtpSendSchema.parse(body)
const hostValidation = await validateDatabaseHost(validatedData.smtpHost, 'smtpHost')
if (!hostValidation.isValid) {
logger.warn(`[${requestId}] SMTP host validation failed`, {
host: validatedData.smtpHost,
error: hostValidation.error,
})
return NextResponse.json({ success: false, error: hostValidation.error }, { status: 400 })
}
logger.info(`[${requestId}] Sending email via SMTP`, {
host: validatedData.smtpHost,
port: validatedData.smtpPort,
@@ -74,13 +64,8 @@ export async function POST(request: NextRequest) {
secure: validatedData.smtpSecure,
})
// Pin the pre-resolved IP to prevent DNS rebinding (TOCTOU) attacks.
// Pass resolvedIP as the host so nodemailer connects to the validated address,
// and set servername for correct TLS SNI/certificate validation.
const pinnedHost = hostValidation.resolvedIP ?? validatedData.smtpHost
const transporter = nodemailer.createTransport({
host: pinnedHost,
host: validatedData.smtpHost,
port: validatedData.smtpPort,
secure: validatedData.smtpSecure === 'SSL',
auth: {
@@ -89,8 +74,12 @@ export async function POST(request: NextRequest) {
},
tls:
validatedData.smtpSecure === 'None'
? { rejectUnauthorized: false, servername: validatedData.smtpHost }
: { rejectUnauthorized: true, servername: validatedData.smtpHost },
? {
rejectUnauthorized: false,
}
: {
rejectUnauthorized: true,
},
})
const contentType = validatedData.contentType || 'text'
@@ -200,16 +189,16 @@ export async function POST(request: NextRequest) {
if (isNodeError(error)) {
if (error.code === 'EAUTH') {
errorMessage = 'SMTP authentication failed - check username and password'
} else if (
error.code === 'ECONNECTION' ||
error.code === 'ECONNREFUSED' ||
error.code === 'ECONNRESET' ||
error.code === 'ETIMEDOUT'
) {
} else if (error.code === 'ECONNECTION' || error.code === 'ECONNREFUSED') {
errorMessage = 'Could not connect to SMTP server - check host and port'
} else if (error.code === 'ECONNRESET') {
errorMessage = 'Connection was reset by SMTP server'
} else if (error.code === 'ETIMEDOUT') {
errorMessage = 'SMTP server connection timeout'
}
}
// Check for SMTP response codes
const hasResponseCode = (err: unknown): err is { responseCode: number } => {
return typeof err === 'object' && err !== null && 'responseCode' in err
}

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { type Attributes, Client, type ConnectConfig } from 'ssh2'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
const logger = createLogger('SSHUtils')
@@ -109,23 +108,16 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
* - keepaliveInterval: 0 (disabled, same as OpenSSH ServerAliveInterval)
* - keepaliveCountMax: 3 (same as OpenSSH ServerAliveCountMax)
*/
export async function createSSHConnection(config: SSHConnectionConfig): Promise<Client> {
const host = config.host
if (!host || host.trim() === '') {
throw new Error('Host is required. Please provide a valid hostname or IP address.')
}
const hostValidation = await validateDatabaseHost(host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
const resolvedHost = hostValidation.resolvedIP ?? host.trim()
export function createSSHConnection(config: SSHConnectionConfig): Promise<Client> {
return new Promise((resolve, reject) => {
const client = new Client()
const port = config.port || 22
const host = config.host
if (!host || host.trim() === '') {
reject(new Error('Host is required. Please provide a valid hostname or IP address.'))
return
}
const hasPassword = config.password && config.password.trim() !== ''
const hasPrivateKey = config.privateKey && config.privateKey.trim() !== ''
@@ -136,7 +128,7 @@ export async function createSSHConnection(config: SSHConnectionConfig): Promise<
}
const connectConfig: ConnectConfig = {
host: resolvedHost,
host: host.trim(),
port,
username: config.username,
}

View File

@@ -1,7 +1,25 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import {
cleanupWebhooksForWorkflow,
restorePreviousVersionWebhooks,
saveTriggerWebhooksForDeploy,
} from '@/lib/webhooks/deploy'
import { getActiveWorkflowRecord } from '@/lib/workflows/active-context'
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
import {
activateWorkflowVersionById,
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -13,19 +31,12 @@ import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/
const logger = createLogger('AdminWorkflowDeployAPI')
const ADMIN_ACTOR_ID = 'admin-api'
interface RouteParams {
id: string
}
/**
* POST — Deploy a workflow via admin API.
*
* `userId` is set to the workflow owner so that webhook credential resolution
* (OAuth token lookups for providers like Airtable, Attio, etc.) uses a real
* user. `actorId` is set to `'admin-api'` so that the `deployedBy` field on
* the deployment version and audit log entries are correctly attributed to an
* admin action rather than the workflow owner.
*/
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
const requestId = generateRequestId()
@@ -37,28 +48,140 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return notFoundResponse('Workflow')
}
const result = await performFullDeploy({
workflowId,
userId: workflowRecord.userId,
workflowName: workflowRecord.name,
requestId,
request,
actorId: 'admin-api',
})
if (!result.success) {
if (result.errorCode === 'not_found') return notFoundResponse('Workflow state')
if (result.errorCode === 'validation') return badRequestResponse(result.error!)
return internalErrorResponse(result.error || 'Failed to deploy workflow')
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return badRequestResponse('Workflow has no saved state')
}
logger.info(`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${result.version}`)
const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks)
if (!scheduleValidation.isValid) {
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const rollbackDeployment = async () => {
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
const reactivateResult = await activateWorkflowVersionById({
workflowId,
deploymentVersionId: previousVersionId,
})
if (reactivateResult.success) {
return
}
}
await undeployWorkflow({ workflowId })
}
const deployResult = await deployWorkflow({
workflowId,
deployedBy: ADMIN_ACTOR_ID,
workflowName: workflowRecord.name,
})
if (!deployResult.success) {
return internalErrorResponse(deployResult.error || 'Failed to deploy workflow')
}
if (!deployResult.deploymentVersionId) {
await undeployWorkflow({ workflowId })
return internalErrorResponse('Failed to resolve deployment version')
}
const workflowData = workflowRecord as Record<string, unknown>
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId,
workflow: workflowData,
userId: workflowRecord.userId,
blocks: normalizedData.blocks,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
previousVersionId,
})
if (!triggerSaveResult.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
})
await rollbackDeployment()
return internalErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration'
)
}
const scheduleResult = await createSchedulesForDeploy(
workflowId,
normalizedData.blocks,
db,
deployResult.deploymentVersionId
)
if (!scheduleResult.success) {
logger.error(
`[${requestId}] Admin API: Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`
)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
})
await rollbackDeployment()
return internalErrorResponse(scheduleResult.error || 'Failed to create schedule')
}
if (previousVersionId && previousVersionId !== deployResult.deploymentVersionId) {
try {
logger.info(`[${requestId}] Admin API: Cleaning up previous version ${previousVersionId}`)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
} catch (cleanupError) {
logger.error(
`[${requestId}] Admin API: Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
}
logger.info(
`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${deployResult.version}`
)
// Sync MCP tools with the latest parameter schema
await syncMcpToolsForWorkflow({ workflowId, requestId, context: 'deploy' })
const response: AdminDeployResult = {
isDeployed: true,
version: result.version!,
deployedAt: result.deployedAt!.toISOString(),
warnings: result.warnings,
version: deployResult.version!,
deployedAt: deployResult.deployedAt!.toISOString(),
warnings: triggerSaveResult.warnings,
}
return singleResponse(response)
@@ -68,7 +191,7 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
}
})
export const DELETE = withAdminAuthParams<RouteParams>(async (_request, context) => {
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
const requestId = generateRequestId()
@@ -79,17 +202,19 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (_request, context)
return notFoundResponse('Workflow')
}
const result = await performFullUndeploy({
workflowId,
userId: workflowRecord.userId,
requestId,
actorId: 'admin-api',
})
const result = await undeployWorkflow({ workflowId })
if (!result.success) {
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
}
await cleanupWebhooksForWorkflow(
workflowId,
workflowRecord as Record<string, unknown>,
requestId
)
await removeMcpToolsForWorkflow(workflowId, requestId)
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
const response: AdminUndeployResult = {

View File

@@ -13,12 +13,12 @@
*/
import { db } from '@sim/db'
import { workflowBlocks, workflowEdges } from '@sim/db/schema'
import { templates, workflowBlocks, workflowEdges } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getActiveWorkflowRecord } from '@/lib/workflows/active-context'
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
import { archiveWorkflow } from '@/lib/workflows/lifecycle'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
@@ -69,7 +69,7 @@ export const GET = withAdminAuthParams<RouteParams>(async (request, context) =>
}
})
export const DELETE = withAdminAuthParams<RouteParams>(async (_request, context) => {
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
try {
@@ -79,17 +79,11 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (_request, context)
return notFoundResponse('Workflow')
}
const result = await performDeleteWorkflow({
workflowId,
userId: workflowData.userId,
skipLastWorkflowGuard: true,
requestId: `admin-workflow-${workflowId}`,
actorId: 'admin-api',
})
await db.update(templates).set({ workflowId: null }).where(eq(templates.workflowId, workflowId))
if (!result.success) {
return internalErrorResponse(result.error || 'Failed to delete workflow')
}
await archiveWorkflow(workflowId, {
requestId: `admin-workflow-${workflowId}`,
})
logger.info(`Admin API: Deleted workflow ${workflowId} (${workflowData.name})`)

View File

@@ -1,7 +1,16 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { getActiveWorkflowRecord } from '@/lib/workflows/active-context'
import { performActivateVersion } from '@/lib/workflows/orchestration'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -9,6 +18,7 @@ import {
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AdminWorkflowActivateVersionAPI')
@@ -33,22 +43,144 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return badRequestResponse('Invalid version number')
}
const result = await performActivateVersion({
workflowId,
version: versionNum,
userId: workflowRecord.userId,
workflow: workflowRecord as Record<string, unknown>,
requestId,
const [versionRow] = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
if (!versionRow?.state) {
return notFoundResponse('Deployment version')
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const deployedState = versionRow.state as { blocks?: Record<string, BlockState> }
const blocks = deployedState.blocks
if (!blocks || typeof blocks !== 'object') {
return internalErrorResponse('Invalid deployed state structure')
}
const workflowData = workflowRecord as Record<string, unknown>
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
actorId: 'admin-api',
workflowId,
workflow: workflowData,
userId: workflowRecord.userId,
blocks,
requestId,
deploymentVersionId: versionRow.id,
previousVersionId,
forceRecreateSubscriptions: true,
})
if (!triggerSaveResult.success) {
logger.error(
`[${requestId}] Admin API: Failed to sync triggers for workflow ${workflowId}`,
triggerSaveResult.error
)
return internalErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration'
)
}
const scheduleResult = await createSchedulesForDeploy(workflowId, blocks, db, versionRow.id)
if (!scheduleResult.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
}
return internalErrorResponse(scheduleResult.error || 'Failed to sync schedules')
}
const result = await activateWorkflowVersion({ workflowId, version: versionNum })
if (!result.success) {
if (result.errorCode === 'not_found') return notFoundResponse('Deployment version')
if (result.errorCode === 'validation') return badRequestResponse(result.error!)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
}
if (result.error === 'Deployment version not found') {
return notFoundResponse('Deployment version')
}
return internalErrorResponse(result.error || 'Failed to activate version')
}
if (previousVersionId && previousVersionId !== versionRow.id) {
try {
logger.info(
`[${requestId}] Admin API: Cleaning up previous version ${previousVersionId} webhooks/schedules`
)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
logger.info(`[${requestId}] Admin API: Previous version cleanup completed`)
} catch (cleanupError) {
logger.error(
`[${requestId}] Admin API: Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
}
await syncMcpToolsForWorkflow({
workflowId,
requestId,
state: versionRow.state,
context: 'activate',
})
logger.info(
`[${requestId}] Admin API: Activated version ${versionNum} for workflow ${workflowId}`
)
@@ -57,12 +189,14 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
success: true,
version: versionNum,
deployedAt: result.deployedAt!.toISOString(),
warnings: result.warnings,
warnings: triggerSaveResult.warnings,
})
} catch (error) {
logger.error(
`[${requestId}] Admin API: Failed to activate version for workflow ${workflowId}`,
{ error }
{
error,
}
)
return internalErrorResponse('Failed to activate deployment version')
}

View File

@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils'
@@ -83,15 +84,17 @@ export async function POST(req: NextRequest) {
const chatId = parsed.chatId || crypto.randomUUID()
messageId = crypto.randomUUID()
const reqLogger = logger.withMetadata({ messageId })
reqLogger.info('Received headless copilot chat start request', {
workflowId: resolved.workflowId,
workflowName: parsed.workflowName,
chatId,
mode: transportMode,
autoExecuteTools: parsed.autoExecuteTools,
timeout: parsed.timeout,
})
logger.error(
appendCopilotLogContext('Received headless copilot chat start request', { messageId }),
{
workflowId: resolved.workflowId,
workflowName: parsed.workflowName,
chatId,
mode: transportMode,
autoExecuteTools: parsed.autoExecuteTools,
timeout: parsed.timeout,
}
)
const requestPayload = {
message: parsed.message,
workflowId: resolved.workflowId,
@@ -141,7 +144,7 @@ export async function POST(req: NextRequest) {
)
}
logger.withMetadata({ messageId }).error('Headless copilot request failed', {
logger.error(appendCopilotLogContext('Headless copilot request failed', { messageId }), {
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })

View File

@@ -1,9 +1,26 @@
import { db, workflow } from '@sim/db'
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import {
cleanupWebhooksForWorkflow,
restorePreviousVersionWebhooks,
saveTriggerWebhooksForDeploy,
} from '@/lib/webhooks/deploy'
import {
activateWorkflowVersionById,
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import {
checkNeedsRedeployment,
@@ -80,22 +97,164 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse('Unable to determine deploying user', 400)
}
const result = await performFullDeploy({
const normalizedData = await loadWorkflowFromNormalizedTables(id)
if (!normalizedData) {
return createErrorResponse('Failed to load workflow state', 500)
}
const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks)
if (!scheduleValidation.isValid) {
logger.warn(
`[${requestId}] Schedule validation failed for workflow ${id}: ${scheduleValidation.error}`
)
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const rollbackDeployment = async () => {
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
previousVersionId,
requestId,
})
const reactivateResult = await activateWorkflowVersionById({
workflowId: id,
deploymentVersionId: previousVersionId,
})
if (reactivateResult.success) {
return
}
}
await undeployWorkflow({ workflowId: id })
}
const deployResult = await deployWorkflow({
workflowId: id,
userId: actorUserId,
workflowName: workflowData!.name || undefined,
requestId,
request,
deployedBy: actorUserId,
workflowName: workflowData!.name,
})
if (!result.success) {
const status =
result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500
return createErrorResponse(result.error || 'Failed to deploy workflow', status)
if (!deployResult.success) {
return createErrorResponse(deployResult.error || 'Failed to deploy workflow', 500)
}
const deployedAt = deployResult.deployedAt!
const deploymentVersionId = deployResult.deploymentVersionId
if (!deploymentVersionId) {
await undeployWorkflow({ workflowId: id })
return createErrorResponse('Failed to resolve deployment version', 500)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId: id,
workflow: workflowData,
userId: actorUserId,
blocks: normalizedData.blocks,
requestId,
deploymentVersionId,
previousVersionId,
})
if (!triggerSaveResult.success) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId,
})
await rollbackDeployment()
return createErrorResponse(
triggerSaveResult.error?.message || 'Failed to save trigger configuration',
triggerSaveResult.error?.status || 500
)
}
let scheduleInfo: { scheduleId?: string; cronExpression?: string; nextRunAt?: Date } = {}
const scheduleResult = await createSchedulesForDeploy(
id,
normalizedData.blocks,
db,
deploymentVersionId
)
if (!scheduleResult.success) {
logger.error(
`[${requestId}] Failed to create schedule for workflow ${id}: ${scheduleResult.error}`
)
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId,
})
await rollbackDeployment()
return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500)
}
if (scheduleResult.scheduleId) {
scheduleInfo = {
scheduleId: scheduleResult.scheduleId,
cronExpression: scheduleResult.cronExpression,
nextRunAt: scheduleResult.nextRunAt,
}
logger.info(
`[${requestId}] Schedule created for workflow ${id}: ${scheduleResult.scheduleId}`
)
}
if (previousVersionId && previousVersionId !== deploymentVersionId) {
try {
logger.info(`[${requestId}] Cleaning up previous version ${previousVersionId} DB records`)
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
} catch (cleanupError) {
logger.error(
`[${requestId}] Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
// Non-fatal - continue with success response
}
}
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
// Sync MCP tools with the latest parameter schema
await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' })
recordAudit({
workspaceId: workflowData?.workspaceId || null,
actorId: actorUserId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.WORKFLOW_DEPLOYED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
resourceName: workflowData?.name,
description: `Deployed workflow "${workflowData?.name || id}"`,
metadata: { version: deploymentVersionId },
request,
})
const responseApiKeyInfo = workflowData!.workspaceId
? 'Workspace API keys'
: 'Personal API keys'
@@ -103,13 +262,25 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createSuccessResponse({
apiKey: responseApiKeyInfo,
isDeployed: true,
deployedAt: result.deployedAt,
warnings: result.warnings,
deployedAt,
schedule: scheduleInfo.scheduleId
? {
id: scheduleInfo.scheduleId,
cronExpression: scheduleInfo.cronExpression,
nextRunAt: scheduleInfo.nextRunAt,
}
: undefined,
warnings: triggerSaveResult.warnings,
})
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to deploy workflow'
logger.error(`[${requestId}] Error deploying workflow: ${id}`, { error })
return createErrorResponse(message, 500)
} catch (error: any) {
logger.error(`[${requestId}] Error deploying workflow: ${id}`, {
error: error.message,
stack: error.stack,
name: error.name,
cause: error.cause,
fullError: error,
})
return createErrorResponse(error.message || 'Failed to deploy workflow', 500)
}
}
@@ -157,36 +328,60 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
export async function DELETE(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = generateRequestId()
const { id } = await params
try {
const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin')
const {
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
const result = await performFullUndeploy({
workflowId: id,
userId: session!.user.id,
requestId,
})
const result = await undeployWorkflow({ workflowId: id })
if (!result.success) {
return createErrorResponse(result.error || 'Failed to undeploy workflow', 500)
}
await cleanupWebhooksForWorkflow(id, workflowData as Record<string, unknown>, requestId)
await removeMcpToolsForWorkflow(id, requestId)
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.workflowUndeployed({ workflowId: id })
} catch (_e) {
// Silently fail
}
recordAudit({
workspaceId: workflowData?.workspaceId || null,
actorId: session!.user.id,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.WORKFLOW_UNDEPLOYED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
resourceName: workflowData?.name,
description: `Undeployed workflow "${workflowData?.name || id}"`,
request,
})
return createSuccessResponse({
isDeployed: false,
deployedAt: null,
apiKey: null,
})
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to undeploy workflow'
logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { error })
return createErrorResponse(message, 500)
} catch (error: any) {
logger.error(`[${requestId}] Error undeploying workflow: ${id}`, error)
return createErrorResponse(error.message || 'Failed to undeploy workflow', 500)
}
}

View File

@@ -3,10 +3,19 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import { performActivateVersion } from '@/lib/workflows/orchestration'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowDeploymentVersionAPI')
@@ -120,25 +129,140 @@ export async function PATCH(
return createErrorResponse('Unable to determine activating user', 400)
}
const activateResult = await performActivateVersion({
workflowId: id,
version: versionNum,
userId: actorUserId,
workflow: workflowData as Record<string, unknown>,
requestId,
request,
})
const [versionRow] = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
if (!activateResult.success) {
const status =
activateResult.errorCode === 'not_found'
? 404
: activateResult.errorCode === 'validation'
? 400
: 500
return createErrorResponse(activateResult.error || 'Failed to activate deployment', status)
if (!versionRow?.state) {
return createErrorResponse('Deployment version not found', 404)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const deployedState = versionRow.state as { blocks?: Record<string, BlockState> }
const blocks = deployedState.blocks
if (!blocks || typeof blocks !== 'object') {
return createErrorResponse('Invalid deployed state structure', 500)
}
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return createErrorResponse(
`Invalid schedule configuration: ${scheduleValidation.error}`,
400
)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId: id,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
blocks,
requestId,
deploymentVersionId: versionRow.id,
previousVersionId,
forceRecreateSubscriptions: true,
})
if (!triggerSaveResult.success) {
return createErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration',
triggerSaveResult.error?.status || 500
)
}
const scheduleResult = await createSchedulesForDeploy(id, blocks, db, versionRow.id)
if (!scheduleResult.success) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
previousVersionId,
requestId,
})
}
return createErrorResponse(scheduleResult.error || 'Failed to sync schedules', 500)
}
const result = await activateWorkflowVersion({ workflowId: id, version: versionNum })
if (!result.success) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
previousVersionId,
requestId,
})
}
return createErrorResponse(result.error || 'Failed to activate deployment', 400)
}
if (previousVersionId && previousVersionId !== versionRow.id) {
try {
logger.info(
`[${requestId}] Cleaning up previous version ${previousVersionId} webhooks/schedules`
)
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
logger.info(`[${requestId}] Previous version cleanup completed`)
} catch (cleanupError) {
logger.error(
`[${requestId}] Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
}
await syncMcpToolsForWorkflow({
workflowId: id,
requestId,
state: versionRow.state,
context: 'activate',
})
// Apply name/description updates if provided alongside activation
let updatedName: string | null | undefined
let updatedDescription: string | null | undefined
if (name !== undefined || description !== undefined) {
@@ -174,10 +298,23 @@ export async function PATCH(
}
}
recordAudit({
workspaceId: workflowData?.workspaceId,
actorId: actorUserId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
description: `Activated deployment version ${versionNum}`,
metadata: { version: versionNum },
request,
})
return createSuccessResponse({
success: true,
deployedAt: activateResult.deployedAt,
warnings: activateResult.warnings,
deployedAt: result.deployedAt,
warnings: triggerSaveResult.warnings,
...(updatedName !== undefined && { name: updatedName }),
...(updatedDescription !== undefined && { description: updatedDescription }),
})

View File

@@ -82,16 +82,14 @@ vi.mock('@/background/workflow-execution', () => ({
executeWorkflowJob: vi.fn(),
}))
vi.mock('@sim/logger', () => {
const createMockLogger = (): Record<string, any> => ({
vi.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
withMetadata: vi.fn(() => createMockLogger()),
})
return { createLogger: vi.fn(() => createMockLogger()) }
})
}),
}))
vi.mock('uuid', () => ({
validate: vi.fn().mockReturnValue(true),

View File

@@ -187,13 +187,6 @@ type AsyncExecutionParams = {
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
const { requestId, workflowId, userId, workspaceId, input, triggerType, executionId, callChain } =
params
const asyncLogger = logger.withMetadata({
requestId,
workflowId,
workspaceId,
userId,
executionId,
})
const correlation = {
executionId,
@@ -240,7 +233,10 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
metadata: { workflowId, userId, correlation },
})
asyncLogger.info('Queued async workflow execution', { jobId })
logger.info(`[${requestId}] Queued async workflow execution`, {
workflowId,
jobId,
})
if (shouldExecuteInline() && jobQueue) {
const inlineJobQueue = jobQueue
@@ -251,14 +247,14 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
await inlineJobQueue.completeJob(jobId, output)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
asyncLogger.error('Async workflow execution failed', {
logger.error(`[${requestId}] Async workflow execution failed`, {
jobId,
error: errorMessage,
})
try {
await inlineJobQueue.markJobFailed(jobId, errorMessage)
} catch (markFailedError) {
asyncLogger.error('Failed to mark job as failed', {
logger.error(`[${requestId}] Failed to mark job as failed`, {
jobId,
error:
markFailedError instanceof Error
@@ -293,7 +289,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
)
}
asyncLogger.error('Failed to queue async execution', error)
logger.error(`[${requestId}] Failed to queue async execution`, error)
return NextResponse.json(
{ error: `Failed to queue async execution: ${error.message}` },
{ status: 500 }
@@ -356,12 +352,11 @@ async function handleExecutePost(
): Promise<NextResponse | Response> {
const requestId = generateRequestId()
const { id: workflowId } = await params
let reqLogger = logger.withMetadata({ requestId, workflowId })
const incomingCallChain = parseCallChain(req.headers.get(SIM_VIA_HEADER))
const callChainError = validateCallChain(incomingCallChain)
if (callChainError) {
reqLogger.warn(`Call chain rejected: ${callChainError}`)
logger.warn(`[${requestId}] Call chain rejected for workflow ${workflowId}: ${callChainError}`)
return NextResponse.json({ error: callChainError }, { status: 409 })
}
const callChain = buildNextCallChain(incomingCallChain, workflowId)
@@ -419,12 +414,12 @@ async function handleExecutePost(
body = JSON.parse(text)
}
} catch (error) {
reqLogger.warn('Failed to parse request body, using defaults')
logger.warn(`[${requestId}] Failed to parse request body, using defaults`)
}
const validation = ExecuteWorkflowSchema.safeParse(body)
if (!validation.success) {
reqLogger.warn('Invalid request body:', validation.error.errors)
logger.warn(`[${requestId}] Invalid request body:`, validation.error.errors)
return NextResponse.json(
{
error: 'Invalid request body',
@@ -594,10 +589,9 @@ async function handleExecutePost(
)
}
const executionId = uuidv4()
reqLogger = reqLogger.withMetadata({ userId, executionId })
reqLogger.info('Starting server-side execution', {
logger.info(`[${requestId}] Starting server-side execution`, {
workflowId,
userId,
hasInput: !!input,
triggerType,
authType: auth.authType,
@@ -606,6 +600,8 @@ async function handleExecutePost(
enableSSE,
isAsyncMode,
})
const executionId = uuidv4()
let loggingTriggerType: CoreTriggerType = 'manual'
if (CORE_TRIGGER_TYPES.includes(triggerType as CoreTriggerType)) {
loggingTriggerType = triggerType as CoreTriggerType
@@ -661,11 +657,10 @@ async function handleExecutePost(
const workflow = preprocessResult.workflowRecord!
if (!workflow.workspaceId) {
reqLogger.error('Workflow has no workspaceId')
logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`)
return NextResponse.json({ error: 'Workflow has no associated workspace' }, { status: 500 })
}
const workspaceId = workflow.workspaceId
reqLogger = reqLogger.withMetadata({ workspaceId, userId: actorUserId })
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workspaceId) {
return NextResponse.json(
@@ -674,7 +669,11 @@ async function handleExecutePost(
)
}
reqLogger.info('Preprocessing passed')
logger.info(`[${requestId}] Preprocessing passed`, {
workflowId,
actorUserId,
workspaceId,
})
if (isAsyncMode) {
return handleAsyncExecution({
@@ -745,7 +744,7 @@ async function handleExecutePost(
)
}
} catch (fileError) {
reqLogger.error('Failed to process input file fields:', fileError)
logger.error(`[${requestId}] Failed to process input file fields:`, fileError)
await loggingSession.safeStart({
userId: actorUserId,
@@ -773,7 +772,7 @@ async function handleExecutePost(
sanitizedWorkflowStateOverride || cachedWorkflowData || undefined
if (!enableSSE) {
reqLogger.info('Using non-SSE execution (direct JSON response)')
logger.info(`[${requestId}] Using non-SSE execution (direct JSON response)`)
const metadata: ExecutionMetadata = {
requestId,
executionId,
@@ -867,7 +866,7 @@ async function handleExecutePost(
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
reqLogger.error(`Queued non-SSE execution failed: ${errorMessage}`)
logger.error(`[${requestId}] Queued non-SSE execution failed: ${errorMessage}`)
return NextResponse.json(
{
@@ -909,7 +908,7 @@ async function handleExecutePost(
timeoutController.timeoutMs
) {
const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs)
reqLogger.info('Non-SSE execution timed out', {
logger.info(`[${requestId}] Non-SSE execution timed out`, {
timeoutMs: timeoutController.timeoutMs,
})
await loggingSession.markAsFailed(timeoutErrorMessage)
@@ -963,7 +962,7 @@ async function handleExecutePost(
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
reqLogger.error(`Non-SSE execution failed: ${errorMessage}`)
logger.error(`[${requestId}] Non-SSE execution failed: ${errorMessage}`)
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
@@ -986,7 +985,7 @@ async function handleExecutePost(
timeoutController.cleanup()
if (executionId) {
void cleanupExecutionBase64Cache(executionId).catch((error) => {
reqLogger.error('Failed to cleanup base64 cache', { error })
logger.error(`[${requestId}] Failed to cleanup base64 cache`, { error })
})
}
}
@@ -1040,9 +1039,9 @@ async function handleExecutePost(
})
}
reqLogger.info('Using SSE console log streaming (manual execution)')
logger.info(`[${requestId}] Using SSE console log streaming (manual execution)`)
} else {
reqLogger.info('Using streaming API response')
logger.info(`[${requestId}] Using streaming API response`)
const resolvedSelectedOutputs = resolveOutputIds(
selectedOutputs,
@@ -1136,7 +1135,7 @@ async function handleExecutePost(
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => {
reqLogger.info('onBlockStart called', { blockId, blockName, blockType })
logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType })
sendEvent({
type: 'block:started',
timestamp: new Date().toISOString(),
@@ -1185,7 +1184,7 @@ async function handleExecutePost(
: {}
if (hasError) {
reqLogger.info('onBlockComplete (error) called', {
logger.info(`[${requestId}] ✗ onBlockComplete (error) called:`, {
blockId,
blockName,
blockType,
@@ -1220,7 +1219,7 @@ async function handleExecutePost(
},
})
} else {
reqLogger.info('onBlockComplete called', {
logger.info(`[${requestId}] ✓ onBlockComplete called:`, {
blockId,
blockName,
blockType,
@@ -1285,7 +1284,7 @@ async function handleExecutePost(
data: { blockId },
})
} catch (error) {
reqLogger.error('Error streaming block content:', error)
logger.error(`[${requestId}] Error streaming block content:`, error)
} finally {
try {
await reader.cancel().catch(() => {})
@@ -1361,7 +1360,9 @@ async function handleExecutePost(
if (result.status === 'paused') {
if (!result.snapshotSeed) {
reqLogger.error('Missing snapshot seed for paused execution')
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
executionId,
})
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
} else {
try {
@@ -1373,7 +1374,8 @@ async function handleExecutePost(
executorUserId: result.metadata?.userId,
})
} catch (pauseError) {
reqLogger.error('Failed to persist pause result', {
logger.error(`[${requestId}] Failed to persist pause result`, {
executionId,
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
})
await loggingSession.markAsFailed(
@@ -1388,7 +1390,7 @@ async function handleExecutePost(
if (result.status === 'cancelled') {
if (timeoutController.isTimedOut() && timeoutController.timeoutMs) {
const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs)
reqLogger.info('Workflow execution timed out', {
logger.info(`[${requestId}] Workflow execution timed out`, {
timeoutMs: timeoutController.timeoutMs,
})
@@ -1406,7 +1408,7 @@ async function handleExecutePost(
})
finalMetaStatus = 'error'
} else {
reqLogger.info('Workflow execution was cancelled')
logger.info(`[${requestId}] Workflow execution was cancelled`)
sendEvent({
type: 'execution:cancelled',
@@ -1450,7 +1452,7 @@ async function handleExecutePost(
? error.message
: 'Unknown error'
reqLogger.error(`SSE execution failed: ${errorMessage}`, { isTimeout })
logger.error(`[${requestId}] SSE execution failed: ${errorMessage}`, { isTimeout })
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
@@ -1473,7 +1475,7 @@ async function handleExecutePost(
try {
await eventWriter.close()
} catch (closeError) {
reqLogger.warn('Failed to close event writer', {
logger.warn(`[${requestId}] Failed to close event writer`, {
error: closeError instanceof Error ? closeError.message : String(closeError),
})
}
@@ -1494,7 +1496,7 @@ async function handleExecutePost(
},
cancel() {
isStreamClosed = true
reqLogger.info('Client disconnected from SSE stream')
logger.info(`[${requestId}] Client disconnected from SSE stream`)
},
})
@@ -1516,7 +1518,7 @@ async function handleExecutePost(
)
}
reqLogger.error('Failed to start workflow execution:', error)
logger.error(`[${requestId}] Failed to start workflow execution:`, error)
return NextResponse.json(
{ error: error.message || 'Failed to start workflow execution' },
{ status: 500 }

View File

@@ -5,7 +5,14 @@
* @vitest-environment node
*/
import { auditMock, envMock, loggerMock, requestUtilsMock, telemetryMock } from '@sim/testing'
import {
auditMock,
envMock,
loggerMock,
requestUtilsMock,
setupGlobalFetchMock,
telemetryMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -14,7 +21,7 @@ const mockCheckSessionOrInternalAuth = vi.fn()
const mockLoadWorkflowFromNormalizedTables = vi.fn()
const mockGetWorkflowById = vi.fn()
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
const mockPerformDeleteWorkflow = vi.fn()
const mockArchiveWorkflow = vi.fn()
const mockDbUpdate = vi.fn()
const mockDbSelect = vi.fn()
@@ -65,8 +72,8 @@ vi.mock('@/lib/workflows/utils', () => ({
}) => mockAuthorizeWorkflowByWorkspacePermission(params),
}))
vi.mock('@/lib/workflows/orchestration', () => ({
performDeleteWorkflow: (...args: unknown[]) => mockPerformDeleteWorkflow(...args),
vi.mock('@/lib/workflows/lifecycle', () => ({
archiveWorkflow: (...args: unknown[]) => mockArchiveWorkflow(...args),
}))
vi.mock('@sim/db', () => ({
@@ -287,7 +294,18 @@ describe('Workflow By ID API Route', () => {
workspacePermission: 'admin',
})
mockPerformDeleteWorkflow.mockResolvedValue({ success: true })
mockDbSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]),
}),
})
mockArchiveWorkflow.mockResolvedValue({
archived: true,
workflow: mockWorkflow,
})
setupGlobalFetchMock({ ok: true })
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'DELETE',
@@ -299,12 +317,6 @@ describe('Workflow By ID API Route', () => {
expect(response.status).toBe(200)
const data = await response.json()
expect(data.success).toBe(true)
expect(mockPerformDeleteWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-123',
})
)
})
it('should allow admin to delete workspace workflow', async () => {
@@ -325,7 +337,19 @@ describe('Workflow By ID API Route', () => {
workspacePermission: 'admin',
})
mockPerformDeleteWorkflow.mockResolvedValue({ success: true })
// Mock db.select() to return multiple workflows so deletion is allowed
mockDbSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]),
}),
})
mockArchiveWorkflow.mockResolvedValue({
archived: true,
workflow: mockWorkflow,
})
setupGlobalFetchMock({ ok: true })
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'DELETE',
@@ -357,10 +381,11 @@ describe('Workflow By ID API Route', () => {
workspacePermission: 'admin',
})
mockPerformDeleteWorkflow.mockResolvedValue({
success: false,
error: 'Cannot delete the only workflow in the workspace',
errorCode: 'validation',
// Mock db.select() to return only 1 workflow (the one being deleted)
mockDbSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
}),
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {

View File

@@ -1,12 +1,13 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { templates, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, ne } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
import { archiveWorkflow } from '@/lib/workflows/lifecycle'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
@@ -183,12 +184,28 @@ export async function DELETE(
)
}
// Check if this is the last workflow in the workspace
if (workflowData.workspaceId) {
const totalWorkflowsInWorkspace = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, workflowData.workspaceId), isNull(workflow.archivedAt)))
if (totalWorkflowsInWorkspace.length <= 1) {
return NextResponse.json(
{ error: 'Cannot delete the only workflow in the workspace' },
{ status: 400 }
)
}
}
// Check if workflow has published templates before deletion
const { searchParams } = new URL(request.url)
const checkTemplates = searchParams.get('check-templates') === 'true'
const deleteTemplatesParam = searchParams.get('deleteTemplates')
if (checkTemplates) {
const { templates } = await import('@sim/db/schema')
// Return template information for frontend to handle
const publishedTemplates = await db
.select({
id: templates.id,
@@ -212,22 +229,49 @@ export async function DELETE(
})
}
const result = await performDeleteWorkflow({
workflowId,
userId,
requestId,
templateAction: deleteTemplatesParam === 'delete' ? 'delete' : 'orphan',
})
// Handle template deletion based on user choice
if (deleteTemplatesParam !== null) {
const deleteTemplates = deleteTemplatesParam === 'delete'
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return NextResponse.json({ error: result.error }, { status })
if (deleteTemplates) {
// Delete all templates associated with this workflow
await db.delete(templates).where(eq(templates.workflowId, workflowId))
logger.info(`[${requestId}] Deleted templates for workflow ${workflowId}`)
} else {
// Orphan the templates (set workflowId to null)
await db
.update(templates)
.set({ workflowId: null })
.where(eq(templates.workflowId, workflowId))
logger.info(`[${requestId}] Orphaned templates for workflow ${workflowId}`)
}
}
const archiveResult = await archiveWorkflow(workflowId, { requestId })
if (!archiveResult.workflow) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`)
recordAudit({
workspaceId: workflowData.workspaceId || null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WORKFLOW_DELETED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: workflowData.name,
description: `Archived workflow "${workflowData.name}"`,
metadata: {
archived: archiveResult.archived,
deleteTemplates: deleteTemplatesParam === 'delete',
},
request,
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
const elapsed = Date.now() - startTime

View File

@@ -6,6 +6,7 @@ import {
updateApiKeyLastUsed,
} from '@/lib/api-key/service'
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env'
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
const logger = createLogger('WorkflowMiddleware')
@@ -80,6 +81,11 @@ export async function validateWorkflowAccess(
}
}
const internalSecret = request.headers.get('X-Internal-Secret')
if (env.INTERNAL_API_SECRET && internalSecret === env.INTERNAL_API_SECRET) {
return { workflow }
}
let apiKeyHeader = null
for (const [key, value] of request.headers.entries()) {
if (key.toLowerCase() === 'x-api-key' && value) {

View File

@@ -79,22 +79,6 @@ vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
}))
vi.mock('@/components/emails', () => ({
WorkspaceInvitationEmail: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/messaging/email/mailer', () => ({
sendEmail: vi.fn().mockResolvedValue({ success: true }),
}))
vi.mock('@/lib/messaging/email/utils', () => ({
getFromEmailAddress: vi.fn().mockReturnValue('noreply@test.com'),
}))
vi.mock('@react-email/render', () => ({
render: vi.fn().mockResolvedValue('<html></html>'),
}))
vi.mock('@sim/db', () => ({
db: {
select: () => mockDbSelect(),
@@ -187,31 +171,9 @@ describe('Workspace Invitation [invitationId] API Route', () => {
})
describe('GET /api/workspaces/invitations/[invitationId]', () => {
it('should return invitation details when caller is the invitee', async () => {
const session = createSession({ userId: mockUser.id, email: 'invited@example.com' })
mockGetSession.mockResolvedValue(session)
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
dbSelectResults = [[mockInvitation], [mockWorkspace]]
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
const params = Promise.resolve({ invitationId: 'invitation-789' })
const response = await GET(request, { params })
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toMatchObject({
id: 'invitation-789',
email: 'invited@example.com',
status: 'pending',
workspaceName: 'Test Workspace',
})
})
it('should return invitation details when caller is a workspace admin', async () => {
it('should return invitation details when called without token', async () => {
const session = createSession({ userId: mockUser.id, email: mockUser.email })
mockGetSession.mockResolvedValue(session)
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
dbSelectResults = [[mockInvitation], [mockWorkspace]]
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
@@ -229,22 +191,6 @@ describe('Workspace Invitation [invitationId] API Route', () => {
})
})
it('should return 403 when caller is neither invitee nor workspace admin', async () => {
const session = createSession({ userId: mockUser.id, email: 'unrelated@example.com' })
mockGetSession.mockResolvedValue(session)
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
dbSelectResults = [[mockInvitation], [mockWorkspace]]
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
const params = Promise.resolve({ invitationId: 'invitation-789' })
const response = await GET(request, { params })
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toEqual({ error: 'Insufficient permissions' })
})
it('should redirect to login when unauthenticated with token', async () => {
mockGetSession.mockResolvedValue(null)

View File

@@ -198,15 +198,6 @@ export async function GET(
)
}
const isInvitee = session.user.email?.toLowerCase() === invitation.email.toLowerCase()
if (!isInvitee) {
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
if (!hasAdminAccess) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
}
return NextResponse.json({
...invitation,
workspaceName: workspaceDetails.name,

View File

@@ -293,7 +293,7 @@ export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps)
<button
onClick={() => handleVerifyOtp()}
disabled={otpValue.length !== 6 || isVerifyingOtp}
className={AUTH_SUBMIT_BTN}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
{isVerifyingOtp ? (
<span className='flex items-center gap-2'>

View File

@@ -1,7 +1,6 @@
'use client'
import { useRouter } from 'next/navigation'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
interface FormErrorStateProps {
@@ -13,7 +12,10 @@ export function FormErrorState({ error }: FormErrorStateProps) {
return (
<StatusPageLayout title='Form Unavailable' description={error}>
<button onClick={() => router.push('/workspace')} className={AUTH_SUBMIT_BTN}>
<button
onClick={() => router.push('/workspace')}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
Return to Workspace
</button>
</StatusPageLayout>

View File

@@ -5,7 +5,6 @@ import { Eye, EyeOff, Loader2 } from 'lucide-react'
import { Input, Label } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
@@ -76,7 +75,7 @@ export function PasswordAuth({ onSubmit, error }: PasswordAuthProps) {
<button
type='submit'
disabled={!password.trim() || isSubmitting}
className={AUTH_SUBMIT_BTN}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
{isSubmitting ? (
<span className='flex items-center gap-2'>

View File

@@ -2,7 +2,6 @@
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
const logger = createLogger('FormError')
@@ -22,7 +21,10 @@ export default function FormError({ error, reset }: FormErrorProps) {
title='Something went wrong'
description='We encountered an error loading this form. Please try again.'
>
<button onClick={reset} className={AUTH_SUBMIT_BTN}>
<button
onClick={reset}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
Try again
</button>
</StatusPageLayout>

View File

@@ -5,7 +5,6 @@ import { createLogger } from '@sim/logger'
import { Loader2 } from 'lucide-react'
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
import {
@@ -323,7 +322,11 @@ export default function Form({ identifier }: { identifier: string }) {
)}
{fields.length > 0 && (
<button type='submit' disabled={isSubmitting} className={AUTH_SUBMIT_BTN}>
<button
type='submit'
disabled={isSubmitting}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
{isSubmitting ? (
<span className='flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />

View File

@@ -3,7 +3,6 @@
import { Loader2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import { AUTH_PRIMARY_CTA_BASE } from '@/app/(auth)/components/auth-button-classes'
interface InviteStatusCardProps {
type: 'login' | 'loading' | 'error' | 'success' | 'invitation' | 'warning'
@@ -56,7 +55,10 @@ export function InviteStatusCard({
<div className='mt-8 w-full max-w-[410px] space-y-3'>
{isExpiredError && (
<button onClick={() => router.push('/')} className={`${AUTH_PRIMARY_CTA_BASE} w-full`}>
<button
onClick={() => router.push('/')}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
Request New Invitation
</button>
)}
@@ -67,9 +69,9 @@ export function InviteStatusCard({
onClick={action.onClick}
disabled={action.disabled || action.loading}
className={cn(
`${AUTH_PRIMARY_CTA_BASE} w-full`,
'inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50',
index !== 0 &&
'border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]'
'border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
)}
>
{action.loading ? (

View File

@@ -218,7 +218,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<meta httpEquiv='x-ua-compatible' content='ie=edge' />
{/* OneDollarStats Analytics */}
<link rel='dns-prefetch' href='https://assets.onedollarstats.com' />
<script defer src='https://assets.onedollarstats.com/stonks.js' />
<PublicEnvScript />

View File

@@ -2,7 +2,6 @@ import type { Metadata } from 'next'
import Link from 'next/link'
import { getNavBlogPosts } from '@/lib/blog/registry'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { AUTH_PRIMARY_CTA_BASE } from '@/app/(auth)/components/auth-button-classes'
import Navbar from '@/app/(home)/components/navbar/navbar'
export const metadata: Metadata = {
@@ -10,6 +9,9 @@ export const metadata: Metadata = {
robots: { index: false, follow: true },
}
const CTA_BASE =
'inline-flex items-center h-[32px] rounded-[5px] border px-2.5 font-[430] font-season text-sm'
export default async function NotFound() {
const blogPosts = await getNavBlogPosts()
return (
@@ -27,7 +29,10 @@ export default async function NotFound() {
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<div className='mt-3 flex items-center gap-2'>
<Link href='/' className={AUTH_PRIMARY_CTA_BASE}>
<Link
href='/'
className={`${CTA_BASE} gap-2 border-white bg-white text-black transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)]`}
>
Return to Home
</Link>
</div>

View File

@@ -2,15 +2,13 @@ import type { Metadata } from 'next'
import { getBaseUrl } from '@/lib/core/utils/urls'
import Landing from '@/app/(home)/landing'
export const revalidate = 3600
export const dynamic = 'force-dynamic'
const baseUrl = getBaseUrl()
export const metadata: Metadata = {
metadataBase: new URL(baseUrl),
title: {
absolute: 'Sim — Build AI Agents & Run Your Agentic Workforce',
},
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description:
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
keywords:

View File

@@ -3,7 +3,6 @@
import { Suspense, useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { InviteLayout } from '@/app/invite/components'
interface UnsubscribeData {
@@ -144,7 +143,10 @@ function UnsubscribeContent() {
</div>
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
<button onClick={() => window.history.back()} className={AUTH_SUBMIT_BTN}>
<button
onClick={() => window.history.back()}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
Go Back
</button>
</div>
@@ -166,7 +168,10 @@ function UnsubscribeContent() {
</div>
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
<button onClick={() => window.close()} className={AUTH_SUBMIT_BTN}>
<button
onClick={() => window.close()}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
Close
</button>
</div>
@@ -188,7 +193,10 @@ function UnsubscribeContent() {
</div>
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>
<button onClick={() => window.close()} className={AUTH_SUBMIT_BTN}>
<button
onClick={() => window.close()}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
Close
</button>
</div>
@@ -214,7 +222,7 @@ function UnsubscribeContent() {
<button
onClick={() => handleUnsubscribe('all')}
disabled={processing || isAlreadyUnsubscribedFromAll}
className={AUTH_SUBMIT_BTN}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
{processing ? (
<span className='flex items-center gap-2'>
@@ -241,7 +249,7 @@ function UnsubscribeContent() {
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeMarketing
}
className={AUTH_SUBMIT_BTN}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
{data?.currentPreferences.unsubscribeMarketing
? 'Unsubscribed from Marketing'
@@ -255,7 +263,7 @@ function UnsubscribeContent() {
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeUpdates
}
className={AUTH_SUBMIT_BTN}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
{data?.currentPreferences.unsubscribeUpdates
? 'Unsubscribed from Updates'
@@ -269,7 +277,7 @@ function UnsubscribeContent() {
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeNotifications
}
className={AUTH_SUBMIT_BTN}
className='inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50'
>
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'

View File

@@ -61,7 +61,7 @@ export const navTourSteps: Step[] = [
target: '[data-tour="nav-tasks"]',
title: 'Tasks',
content:
'Tasks that work for you. Mothership can create, edit, and delete resources throughout the platform. It can also perform actions on your behalf, like sending emails, creating tasks, and more.',
'Tasks that work for you. Mothership can create, edit, and delete resource throughout the platform. It can also perform actions on your behalf, like sending emails, creating tasks, and more.',
placement: 'right',
disableBeacon: true,
},

View File

@@ -16,6 +16,7 @@ const Joyride = dynamic(() => import('react-joyride'), {
ssr: false,
})
const NAV_TOUR_STORAGE_KEY = 'sim-nav-tour-completed-v1'
export const START_NAV_TOUR_EVENT = 'start-nav-tour'
export function NavTour() {
@@ -24,6 +25,9 @@ export function NavTour() {
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
steps: navTourSteps,
storageKey: NAV_TOUR_STORAGE_KEY,
autoStartDelay: 1200,
resettable: true,
triggerEvent: START_NAV_TOUR_EVENT,
tourName: 'Navigation tour',
disabled: isWorkflowPage,

View File

@@ -1,6 +1,6 @@
'use client'
import { createContext, useCallback, useContext } from 'react'
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import type { TooltipRenderProps } from 'react-joyride'
import { TourTooltip } from '@/components/emcn'
@@ -59,14 +59,18 @@ export function TourTooltipAdapter({
closeProps,
}: TooltipRenderProps) {
const { isTooltipVisible, isEntrance, totalSteps } = useContext(TourStateContext)
const [targetEl, setTargetEl] = useState<HTMLElement | null>(null)
const { target } = step
const targetEl =
typeof target === 'string'
? document.querySelector<HTMLElement>(target)
: target instanceof HTMLElement
? target
: null
useEffect(() => {
const { target } = step
if (typeof target === 'string') {
setTargetEl(document.querySelector<HTMLElement>(target))
} else if (target instanceof HTMLElement) {
setTargetEl(target)
} else {
setTargetEl(null)
}
}, [step])
/**
* Forwards the Joyride tooltip ref safely, handling both

View File

@@ -12,11 +12,17 @@ const FADE_OUT_MS = 80
interface UseTourOptions {
/** Tour step definitions */
steps: Step[]
/** localStorage key for completion persistence */
storageKey: string
/** Delay before auto-starting the tour (ms) */
autoStartDelay?: number
/** Whether this tour can be reset/retriggered */
resettable?: boolean
/** Custom event name to listen for manual triggers */
triggerEvent?: string
/** Identifier for logging */
tourName?: string
/** When true, stops a running tour (e.g. navigating away from the relevant page) */
/** When true, suppresses auto-start (e.g. to avoid overlapping with another active tour) */
disabled?: boolean
}
@@ -35,14 +41,49 @@ interface UseTourReturn {
handleCallback: (data: CallBackProps) => void
}
function isTourCompleted(storageKey: string): boolean {
try {
return localStorage.getItem(storageKey) === 'true'
} catch {
return false
}
}
function markTourCompleted(storageKey: string): void {
try {
localStorage.setItem(storageKey, 'true')
} catch {
logger.warn('Failed to persist tour completion', { storageKey })
}
}
function clearTourCompletion(storageKey: string): void {
try {
localStorage.removeItem(storageKey)
} catch {
logger.warn('Failed to clear tour completion', { storageKey })
}
}
/**
* Tracks which tours have already attempted auto-start in this page session.
* Module-level so it survives component remounts (e.g. navigating between
* workflows remounts WorkflowTour), while still resetting on full page reload.
*/
const autoStartAttempted = new Set<string>()
/**
* Shared hook for managing product tour state with smooth transitions.
*
* Handles manual triggering via custom events and coordinated fade
* Handles auto-start on first visit, localStorage persistence,
* manual triggering via custom events, and coordinated fade
* transitions between steps to prevent layout shift.
*/
export function useTour({
steps,
storageKey,
autoStartDelay = 1200,
resettable = false,
triggerEvent,
tourName = 'tour',
disabled = false,
@@ -53,10 +94,15 @@ export function useTour({
const [isTooltipVisible, setIsTooltipVisible] = useState(true)
const [isEntrance, setIsEntrance] = useState(true)
const disabledRef = useRef(disabled)
const retriggerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rafRef = useRef<number | null>(null)
useEffect(() => {
disabledRef.current = disabled
}, [disabled])
/**
* Schedules a two-frame rAF to reveal the tooltip after the browser
* finishes repositioning. Stores the outer frame ID in `rafRef` so
@@ -91,7 +137,8 @@ export function useTour({
setRun(false)
setIsTooltipVisible(true)
setIsEntrance(true)
}, [cancelPendingTransitions])
markTourCompleted(storageKey)
}, [storageKey, cancelPendingTransitions])
/** Transition to a new step with a coordinated fade-out/fade-in */
const transitionToStep = useCallback(
@@ -114,30 +161,43 @@ export function useTour({
[steps.length, stopTour, cancelPendingTransitions, scheduleReveal]
)
useEffect(() => {
if (!run) return
const html = document.documentElement
const prev = html.style.scrollbarGutter
html.style.scrollbarGutter = 'stable'
return () => {
html.style.scrollbarGutter = prev
}
}, [run])
/** Stop the tour when disabled becomes true (e.g. navigating away from the relevant page) */
useEffect(() => {
if (disabled && run) {
stopTour()
cancelPendingTransitions()
setRun(false)
setIsTooltipVisible(true)
setIsEntrance(true)
logger.info(`${tourName} paused — disabled became true`)
}
}, [disabled, run, tourName, stopTour])
}, [disabled, run, tourName, cancelPendingTransitions])
/** Auto-start on first visit (once per page session per tour) */
useEffect(() => {
if (disabled || autoStartAttempted.has(storageKey) || isTourCompleted(storageKey)) return
const timer = setTimeout(() => {
if (disabledRef.current) return
autoStartAttempted.add(storageKey)
setStepIndex(0)
setIsEntrance(true)
setIsTooltipVisible(false)
setRun(true)
logger.info(`Auto-starting ${tourName}`)
scheduleReveal()
}, autoStartDelay)
return () => clearTimeout(timer)
}, [disabled, storageKey, autoStartDelay, tourName, scheduleReveal])
/** Listen for manual trigger events */
useEffect(() => {
if (!triggerEvent) return
if (!triggerEvent || !resettable) return
const handleTrigger = () => {
setRun(false)
clearTourCompletion(storageKey)
setTourKey((k) => k + 1)
if (retriggerTimerRef.current) {
@@ -162,7 +222,7 @@ export function useTour({
clearTimeout(retriggerTimerRef.current)
}
}
}, [triggerEvent, tourName, scheduleReveal])
}, [triggerEvent, resettable, storageKey, tourName, scheduleReveal])
/** Clean up all pending async work on unmount */
useEffect(() => {

View File

@@ -15,15 +15,19 @@ const Joyride = dynamic(() => import('react-joyride'), {
ssr: false,
})
const WORKFLOW_TOUR_STORAGE_KEY = 'sim-workflow-tour-completed-v1'
export const START_WORKFLOW_TOUR_EVENT = 'start-workflow-tour'
/**
* Workflow tour that covers the canvas, blocks, copilot, and deployment.
* Triggered via "Take a tour" in the sidebar menu.
* Runs on first workflow visit and can be retriggered via "Take a tour".
*/
export function WorkflowTour() {
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
steps: workflowTourSteps,
storageKey: WORKFLOW_TOUR_STORAGE_KEY,
autoStartDelay: 800,
resettable: true,
triggerEvent: START_WORKFLOW_TOUR_EVENT,
tourName: 'Workflow tour',
})

View File

@@ -1,4 +1,4 @@
import { memo, type ReactNode } from 'react'
import { memo, type ReactNode, useCallback, useRef, useState } from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import {
ArrowDown,
@@ -19,6 +19,8 @@ import { cn } from '@/lib/core/utils/cn'
const SEARCH_ICON = (
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
)
const FILTER_ICON = <ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
const SORT_ICON = <ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
type SortDirection = 'asc' | 'desc'
@@ -65,12 +67,7 @@ export interface SearchConfig {
interface ResourceOptionsBarProps {
search?: SearchConfig
sort?: SortConfig
/** Popover content — renders inside a Popover (used by logs, etc.) */
filter?: ReactNode
/** When provided, Filter button acts as a toggle instead of opening a Popover */
onFilterToggle?: () => void
/** Whether the filter is currently active (highlights the toggle button) */
filterActive?: boolean
filterTags?: FilterTag[]
extras?: ReactNode
}
@@ -79,13 +76,10 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
search,
sort,
filter,
onFilterToggle,
filterActive,
filterTags,
extras,
}: ResourceOptionsBarProps) {
const hasContent =
search || sort || filter || onFilterToggle || extras || (filterTags && filterTags.length > 0)
const hasContent = search || sort || filter || extras || (filterTags && filterTags.length > 0)
if (!hasContent) return null
return (
@@ -94,39 +88,22 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
{search && <SearchSection search={search} />}
<div className='flex items-center gap-1.5'>
{extras}
{filterTags?.map((tag, i) => (
{filterTags?.map((tag) => (
<Button
key={`${tag.label}-${i}`}
key={tag.label}
variant='subtle'
className='max-w-[200px] px-2 py-1 text-caption'
className='px-2 py-1 text-caption'
onClick={tag.onRemove}
>
<span className='truncate'>{tag.label}</span>
<span className='ml-1 shrink-0 text-[var(--text-icon)] text-micro'></span>
{tag.label}
<span className='ml-1 text-[var(--text-icon)] text-micro'></span>
</Button>
))}
{onFilterToggle ? (
<Button
variant='subtle'
className={cn(
'px-2 py-1 text-caption',
filterActive && 'bg-[var(--surface-3)] text-[var(--text-primary)]'
)}
onClick={onFilterToggle}
>
<ListFilter
className={cn(
'mr-1.5 h-[14px] w-[14px]',
filterActive ? 'text-[var(--text-primary)]' : 'text-[var(--text-icon)]'
)}
/>
Filter
</Button>
) : filter ? (
{filter && (
<PopoverPrimitive.Root>
<PopoverPrimitive.Trigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{FILTER_ICON}
Filter
</Button>
</PopoverPrimitive.Trigger>
@@ -134,13 +111,15 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
<PopoverPrimitive.Content
align='start'
sideOffset={6}
className='z-50 w-fit rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
className={cn(
'z-50 rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
)}
>
{filter}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
) : null}
)}
{sort && <SortDropdown config={sort} />}
</div>
</div>
@@ -149,6 +128,34 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
})
const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) {
const [localValue, setLocalValue] = useState(search.value)
const lastReportedRef = useRef(search.value)
if (search.value !== lastReportedRef.current) {
setLocalValue(search.value)
lastReportedRef.current = search.value
}
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value
setLocalValue(next)
search.onChange(next)
},
[search.onChange]
)
const handleClearAll = useCallback(() => {
setLocalValue('')
lastReportedRef.current = ''
if (search.onClearAll) {
search.onClearAll()
} else {
search.onChange('')
}
}, [search.onClearAll, search.onChange])
return (
<div className='relative flex flex-1 items-center'>
{SEARCH_ICON}
@@ -170,8 +177,8 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
<input
ref={search.inputRef}
type='text'
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
value={localValue}
onChange={handleInputChange}
onKeyDown={search.onKeyDown}
onFocus={search.onFocus}
onBlur={search.onBlur}
@@ -179,11 +186,11 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
{search.tags?.length || search.value ? (
{search.tags?.length || localValue ? (
<button
type='button'
className='mr-0.5 flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
onClick={search.onClearAll ?? (() => search.onChange(''))}
onClick={handleClearAll}
>
<span className='text-caption'></span>
</button>
@@ -206,19 +213,8 @@ const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='subtle'
className={cn(
'px-2 py-1 text-caption',
active && 'bg-[var(--surface-3)] text-[var(--text-primary)]'
)}
>
<ArrowUpDown
className={cn(
'mr-1.5 h-[14px] w-[14px]',
active ? 'text-[var(--text-primary)]' : 'text-[var(--text-icon)]'
)}
/>
<Button variant='subtle' className='px-2 py-1 text-caption'>
{SORT_ICON}
Sort
</Button>
</DropdownMenuTrigger>

View File

@@ -2,7 +2,6 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ZoomIn, ZoomOut } from 'lucide-react'
import { Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
@@ -291,16 +290,6 @@ function TextEditor({
}
}, [isResizing])
const handleCheckboxToggle = useCallback(
(checkboxIndex: number, checked: boolean) => {
const toggled = toggleMarkdownCheckbox(contentRef.current, checkboxIndex, checked)
if (toggled !== contentRef.current) {
handleContentChange(toggled)
}
},
[handleContentChange]
)
const isStreaming = streamingContent !== undefined
const revealedContent = useStreamingText(content, isStreaming)
@@ -403,11 +392,10 @@ function TextEditor({
className={cn('min-w-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}
>
<PreviewPanel
content={isStreaming ? revealedContent : content}
content={revealedContent}
mimeType={file.type}
filename={file.name}
isStreaming={isStreaming}
onCheckboxToggle={canEdit && !isStreaming ? handleCheckboxToggle : undefined}
/>
</div>
</>
@@ -433,120 +421,17 @@ const IframePreview = memo(function IframePreview({ file }: { file: WorkspaceFil
)
})
const ZOOM_MIN = 0.25
const ZOOM_MAX = 4
const ZOOM_WHEEL_SENSITIVITY = 0.005
const ZOOM_BUTTON_FACTOR = 1.2
const clampZoom = (z: number) => Math.min(Math.max(z, ZOOM_MIN), ZOOM_MAX)
const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
const [zoom, setZoom] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const isDragging = useRef(false)
const dragStart = useRef({ x: 0, y: 0 })
const offsetAtDragStart = useRef({ x: 0, y: 0 })
const offsetRef = useRef(offset)
offsetRef.current = offset
const containerRef = useRef<HTMLDivElement>(null)
const zoomIn = useCallback(() => setZoom((z) => clampZoom(z * ZOOM_BUTTON_FACTOR)), [])
const zoomOut = useCallback(() => setZoom((z) => clampZoom(z / ZOOM_BUTTON_FACTOR)), [])
useEffect(() => {
const el = containerRef.current
if (!el) return
const onWheel = (e: WheelEvent) => {
e.preventDefault()
if (e.ctrlKey || e.metaKey) {
setZoom((z) => clampZoom(z * Math.exp(-e.deltaY * ZOOM_WHEEL_SENSITIVITY)))
} else {
setOffset((o) => ({ x: o.x - e.deltaX, y: o.y - e.deltaY }))
}
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [])
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return
isDragging.current = true
dragStart.current = { x: e.clientX, y: e.clientY }
offsetAtDragStart.current = offsetRef.current
if (containerRef.current) containerRef.current.style.cursor = 'grabbing'
e.preventDefault()
}, [])
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging.current) return
setOffset({
x: offsetAtDragStart.current.x + (e.clientX - dragStart.current.x),
y: offsetAtDragStart.current.y + (e.clientY - dragStart.current.y),
})
}, [])
const handleMouseUp = useCallback(() => {
isDragging.current = false
if (containerRef.current) containerRef.current.style.cursor = 'grab'
}, [])
useEffect(() => {
setZoom(1)
setOffset({ x: 0, y: 0 })
}, [file.key])
return (
<div
ref={containerRef}
className='relative flex flex-1 cursor-grab overflow-hidden bg-[var(--surface-1)]'
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
className='pointer-events-none absolute inset-0 flex items-center justify-center'
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
transformOrigin: 'center center',
}}
>
<img
src={serveUrl}
alt={file.name}
className='max-h-full max-w-full select-none rounded-md object-contain'
draggable={false}
loading='eager'
/>
</div>
<div
className='absolute right-4 bottom-4 flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 shadow-sm'
onMouseDown={(e) => e.stopPropagation()}
>
<button
type='button'
onClick={zoomOut}
disabled={zoom <= ZOOM_MIN}
className='flex h-6 w-6 items-center justify-center rounded text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40'
aria-label='Zoom out'
>
<ZoomOut className='h-3.5 w-3.5' />
</button>
<span className='min-w-[3rem] text-center text-[11px] text-[var(--text-secondary)]'>
{Math.round(zoom * 100)}%
</span>
<button
type='button'
onClick={zoomIn}
disabled={zoom >= ZOOM_MAX}
className='flex h-6 w-6 items-center justify-center rounded text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40'
aria-label='Zoom in'
>
<ZoomIn className='h-3.5 w-3.5' />
</button>
</div>
<div className='flex flex-1 items-center justify-center overflow-auto bg-[var(--surface-1)] p-6'>
<img
src={serveUrl}
alt={file.name}
className='max-h-full max-w-full rounded-md object-contain'
loading='eager'
/>
</div>
)
})
@@ -818,14 +703,6 @@ function PptxPreview({
)
}
function toggleMarkdownCheckbox(markdown: string, targetIndex: number, checked: boolean): string {
let currentIndex = 0
return markdown.replace(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm, (match, prefix: string) => {
if (currentIndex++ !== targetIndex) return match
return `${prefix}[${checked ? 'x' : ' '}]`
})
}
const UnsupportedPreview = memo(function UnsupportedPreview({
file,
}: {

View File

@@ -1,11 +1,9 @@
'use client'
import { createContext, memo, useContext, useMemo, useRef } from 'react'
import type { Components, ExtraProps } from 'react-markdown'
import { memo, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import { Checkbox } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useAutoScroll } from '@/hooks/use-auto-scroll'
@@ -42,7 +40,6 @@ interface PreviewPanelProps {
mimeType: string | null
filename: string
isStreaming?: boolean
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
}
export const PreviewPanel = memo(function PreviewPanel({
@@ -50,18 +47,11 @@ export const PreviewPanel = memo(function PreviewPanel({
mimeType,
filename,
isStreaming,
onCheckboxToggle,
}: PreviewPanelProps) {
const previewType = resolvePreviewType(mimeType, filename)
if (previewType === 'markdown')
return (
<MarkdownPreview
content={content}
isStreaming={isStreaming}
onCheckboxToggle={onCheckboxToggle}
/>
)
return <MarkdownPreview content={content} isStreaming={isStreaming} />
if (previewType === 'html') return <HtmlPreview content={content} />
if (previewType === 'csv') return <CsvPreview content={content} />
if (previewType === 'svg') return <SvgPreview content={content} />
@@ -71,51 +61,45 @@ export const PreviewPanel = memo(function PreviewPanel({
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
/**
* Carries the contentRef and toggle handler from MarkdownPreview down to the
* task-list renderers. Only present when the preview is interactive.
*/
const MarkdownCheckboxCtx = createContext<{
contentRef: React.MutableRefObject<string>
onToggle: (index: number, checked: boolean) => void
} | null>(null)
/** Carries the resolved checkbox index from LiRenderer to InputRenderer. */
const CheckboxIndexCtx = createContext(-1)
const STATIC_MARKDOWN_COMPONENTS = {
p: ({ children }: { children?: React.ReactNode }) => (
const PREVIEW_MARKDOWN_COMPONENTS = {
p: ({ children }: any) => (
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
{children}
</p>
),
h1: ({ children }: { children?: React.ReactNode }) => (
h1: ({ children }: any) => (
<h1 className='mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'>
{children}
</h1>
),
h2: ({ children }: { children?: React.ReactNode }) => (
h2: ({ children }: any) => (
<h2 className='mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'>
{children}
</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
h3: ({ children }: any) => (
<h3 className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'>
{children}
</h3>
),
h4: ({ children }: { children?: React.ReactNode }) => (
h4: ({ children }: any) => (
<h4 className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'>
{children}
</h4>
),
code: ({
className,
children,
node: _node,
...props
}: React.HTMLAttributes<HTMLElement> & ExtraProps) => {
const isInline = !className?.includes('language-')
ul: ({ children }: any) => (
<ul className='mt-1 mb-3 list-disc space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
{children}
</ul>
),
ol: ({ children }: any) => (
<ol className='mt-1 mb-3 list-decimal space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
{children}
</ol>
),
li: ({ children }: any) => <li className='break-words leading-[1.6]'>{children}</li>,
code: ({ inline, className, children, ...props }: any) => {
const isInline = inline || !className?.includes('language-')
if (isInline) {
return (
@@ -137,8 +121,8 @@ const STATIC_MARKDOWN_COMPONENTS = {
</code>
)
},
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
pre: ({ children }: any) => <>{children}</>,
a: ({ href, children }: any) => (
<a
href={href}
target='_blank'
@@ -148,175 +132,59 @@ const STATIC_MARKDOWN_COMPONENTS = {
{children}
</a>
),
strong: ({ children }: { children?: React.ReactNode }) => (
strong: ({ children }: any) => (
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
),
em: ({ children }: { children?: React.ReactNode }) => (
em: ({ children }: any) => (
<em className='break-words text-[var(--text-tertiary)]'>{children}</em>
),
blockquote: ({ children }: { children?: React.ReactNode }) => (
blockquote: ({ children }: any) => (
<blockquote className='my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic'>
{children}
</blockquote>
),
hr: () => <hr className='my-6 border-[var(--border)]' />,
img: ({ src, alt, node: _node }: React.ComponentPropsWithoutRef<'img'> & ExtraProps) => (
img: ({ src, alt }: any) => (
<img src={src} alt={alt ?? ''} className='my-3 max-w-full rounded-md' loading='lazy' />
),
table: ({ children }: { children?: React.ReactNode }) => (
table: ({ children }: any) => (
<div className='my-4 max-w-full overflow-x-auto'>
<table className='w-full border-collapse text-[13px]'>{children}</table>
</div>
),
thead: ({ children }: { children?: React.ReactNode }) => (
<thead className='bg-[var(--surface-2)]'>{children}</thead>
),
tbody: ({ children }: { children?: React.ReactNode }) => <tbody>{children}</tbody>,
tr: ({ children }: { children?: React.ReactNode }) => (
thead: ({ children }: any) => <thead className='bg-[var(--surface-2)]'>{children}</thead>,
tbody: ({ children }: any) => <tbody>{children}</tbody>,
tr: ({ children }: any) => (
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
),
th: ({ children }: { children?: React.ReactNode }) => (
th: ({ children }: any) => (
<th className='px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'>
{children}
</th>
),
td: ({ children }: { children?: React.ReactNode }) => (
<td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>
),
td: ({ children }: any) => <td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>,
}
function UlRenderer({ className, children }: React.ComponentPropsWithoutRef<'ul'> & ExtraProps) {
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
return (
<ul
className={cn(
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
isTaskList ? 'list-none pl-0' : 'list-disc pl-6'
)}
>
{children}
</ul>
)
}
function OlRenderer({ className, children }: React.ComponentPropsWithoutRef<'ol'> & ExtraProps) {
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
return (
<ol
className={cn(
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
isTaskList ? 'list-none pl-0' : 'list-decimal pl-6'
)}
>
{children}
</ol>
)
}
function LiRenderer({
className,
children,
node,
}: React.ComponentPropsWithoutRef<'li'> & ExtraProps) {
const ctx = useContext(MarkdownCheckboxCtx)
const isTaskItem = typeof className === 'string' && className.includes('task-list-item')
if (isTaskItem) {
if (ctx) {
const offset = node?.position?.start?.offset
if (offset === undefined) {
return <li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
}
const before = ctx.contentRef.current.slice(0, offset)
const prior = before.match(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm)
return (
<CheckboxIndexCtx.Provider value={prior ? prior.length : 0}>
<li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
</CheckboxIndexCtx.Provider>
)
}
return <li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
}
return <li className='break-words leading-[1.6]'>{children}</li>
}
function InputRenderer({
type,
checked,
node: _node,
...props
}: React.ComponentPropsWithoutRef<'input'> & ExtraProps) {
const ctx = useContext(MarkdownCheckboxCtx)
const index = useContext(CheckboxIndexCtx)
if (type !== 'checkbox') return <input type={type} checked={checked} {...props} />
const isInteractive = ctx !== null && index >= 0
return (
<Checkbox
checked={checked ?? false}
onCheckedChange={
isInteractive ? (newChecked) => ctx.onToggle(index, Boolean(newChecked)) : undefined
}
disabled={!isInteractive}
size='sm'
className='mt-1 shrink-0'
/>
)
}
const MARKDOWN_COMPONENTS = {
...STATIC_MARKDOWN_COMPONENTS,
ul: UlRenderer,
ol: OlRenderer,
li: LiRenderer,
input: InputRenderer,
} satisfies Components
const MarkdownPreview = memo(function MarkdownPreview({
content,
isStreaming = false,
onCheckboxToggle,
}: {
content: string
isStreaming?: boolean
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
}) {
const { ref: scrollRef } = useAutoScroll(isStreaming)
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
const contentRef = useRef(content)
contentRef.current = content
const ctxValue = useMemo(
() => (onCheckboxToggle ? { contentRef, onToggle: onCheckboxToggle } : null),
[onCheckboxToggle]
)
const committedMarkdown = useMemo(
() =>
committed ? (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
{committed}
</ReactMarkdown>
) : null,
[committed]
)
if (onCheckboxToggle) {
return (
<MarkdownCheckboxCtx.Provider value={ctxValue}>
<div ref={scrollRef} className='h-full overflow-auto p-6'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
</div>
</MarkdownCheckboxCtx.Provider>
)
}
return (
<div ref={scrollRef} className='h-full overflow-auto p-6'>
{committedMarkdown}
@@ -325,7 +193,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
key={generation}
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
{incoming}
</ReactMarkdown>
</div>

View File

@@ -6,8 +6,6 @@ import { useParams, useRouter } from 'next/navigation'
import {
Button,
Columns2,
Combobox,
type ComboboxOption,
Download,
DropdownMenu,
DropdownMenuContent,
@@ -33,22 +31,17 @@ import {
formatFileSize,
getFileExtension,
getMimeTypeFromExtension,
isAudioFileType,
isVideoFileType,
} from '@/lib/uploads/utils/file-utils'
import {
isSupportedExtension,
SUPPORTED_AUDIO_EXTENSIONS,
SUPPORTED_DOCUMENT_EXTENSIONS,
SUPPORTED_VIDEO_EXTENSIONS,
} from '@/lib/uploads/utils/validation'
import type {
FilterTag,
HeaderAction,
ResourceColumn,
ResourceRow,
SearchConfig,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import {
InlineRenameInput,
@@ -73,7 +66,6 @@ import {
useUploadWorkspaceFile,
useWorkspaceFiles,
} from '@/hooks/queries/workspace-files'
import { useDebounce } from '@/hooks/use-debounce'
import { useInlineRename } from '@/hooks/use-inline-rename'
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
@@ -94,6 +86,7 @@ const COLUMNS: ResourceColumn[] = [
{ id: 'type', header: 'Type' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
const MIME_TYPE_LABELS: Record<string, string> = {
@@ -168,14 +161,16 @@ export function Files() {
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const [inputValue, setInputValue] = useState('')
const debouncedSearchTerm = useDebounce(inputValue, 200)
const [activeSort, setActiveSort] = useState<{
column: string
direction: 'asc' | 'desc'
} | null>(null)
const [typeFilter, setTypeFilter] = useState<string[]>([])
const [sizeFilter, setSizeFilter] = useState<string[]>([])
const [uploadedByFilter, setUploadedByFilter] = useState<string[]>([])
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('')
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
const handleSearchChange = useCallback((value: string) => {
setInputValue(value)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchTimerRef.current = setTimeout(() => {
setDebouncedSearchTerm(value)
}, 200)
}, [])
const [creatingFile, setCreatingFile] = useState(false)
const [isDirty, setIsDirty] = useState(false)
@@ -211,60 +206,10 @@ export function Files() {
selectedFileRef.current = selectedFile
const filteredFiles = useMemo(() => {
let result = debouncedSearchTerm
? files.filter((f) => f.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
: files
if (typeFilter.length > 0) {
result = result.filter((f) => {
const ext = getFileExtension(f.name)
if (typeFilter.includes('document') && isSupportedExtension(ext)) return true
if (typeFilter.includes('audio') && isAudioFileType(f.type)) return true
if (typeFilter.includes('video') && isVideoFileType(f.type)) return true
return false
})
}
if (sizeFilter.length > 0) {
result = result.filter((f) => {
if (sizeFilter.includes('small') && f.size < 1_048_576) return true
if (sizeFilter.includes('medium') && f.size >= 1_048_576 && f.size <= 10_485_760)
return true
if (sizeFilter.includes('large') && f.size > 10_485_760) return true
return false
})
}
if (uploadedByFilter.length > 0) {
result = result.filter((f) => uploadedByFilter.includes(f.uploadedBy))
}
const col = activeSort?.column ?? 'created'
const dir = activeSort?.direction ?? 'desc'
return [...result].sort((a, b) => {
let cmp = 0
switch (col) {
case 'name':
cmp = a.name.localeCompare(b.name)
break
case 'size':
cmp = a.size - b.size
break
case 'type':
cmp = formatFileType(a.type, a.name).localeCompare(formatFileType(b.type, b.name))
break
case 'created':
cmp = new Date(a.uploadedAt).getTime() - new Date(b.uploadedAt).getTime()
break
case 'owner':
cmp = (members?.find((m) => m.userId === a.uploadedBy)?.name ?? '').localeCompare(
members?.find((m) => m.userId === b.uploadedBy)?.name ?? ''
)
break
}
return dir === 'asc' ? cmp : -cmp
})
}, [files, debouncedSearchTerm, typeFilter, sizeFilter, uploadedByFilter, activeSort, members])
if (!debouncedSearchTerm) return files
const q = debouncedSearchTerm.toLowerCase()
return files.filter((f) => f.name.toLowerCase().includes(q))
}, [files, debouncedSearchTerm])
const rowCacheRef = useRef(
new Map<string, { row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }>()
@@ -300,6 +245,12 @@ export function Files() {
},
created: timeCell(file.uploadedAt),
owner: ownerCell(file.uploadedBy, members),
updated: timeCell(file.uploadedAt),
},
sortValues: {
size: file.size,
created: -new Date(file.uploadedAt).getTime(),
updated: -new Date(file.uploadedAt).getTime(),
},
}
nextCache.set(file.id, { row, file, members })
@@ -391,7 +342,7 @@ export function Files() {
}
}
},
[workspaceId, uploadFile]
[workspaceId]
)
const handleDownload = useCallback(async (file: WorkspaceFileRecord) => {
@@ -739,6 +690,7 @@ export function Files() {
handleDeleteSelected,
])
/** Stable refs for values used in callbacks to avoid dependency churn */
const listRenameRef = useRef(listRename)
listRenameRef.current = listRename
const headerRenameRef = useRef(headerRename)
@@ -759,14 +711,18 @@ export function Files() {
const canEdit = userPermissions.canEdit === true
const handleSearchClearAll = useCallback(() => {
handleSearchChange('')
}, [handleSearchChange])
const searchConfig: SearchConfig = useMemo(
() => ({
value: inputValue,
onChange: setInputValue,
onClearAll: () => setInputValue(''),
onChange: handleSearchChange,
onClearAll: handleSearchClearAll,
placeholder: 'Search files...',
}),
[inputValue]
[inputValue, handleSearchChange, handleSearchClearAll]
)
const createConfig = useMemo(
@@ -808,205 +764,6 @@ export function Files() {
[handleNavigateToFiles]
)
const typeDisplayLabel = useMemo(() => {
if (typeFilter.length === 0) return 'All'
if (typeFilter.length === 1) {
const labels: Record<string, string> = {
document: 'Documents',
audio: 'Audio',
video: 'Video',
}
return labels[typeFilter[0]] ?? typeFilter[0]
}
return `${typeFilter.length} selected`
}, [typeFilter])
const sizeDisplayLabel = useMemo(() => {
if (sizeFilter.length === 0) return 'All'
if (sizeFilter.length === 1) {
const labels: Record<string, string> = { small: 'Small', medium: 'Medium', large: 'Large' }
return labels[sizeFilter[0]] ?? sizeFilter[0]
}
return `${sizeFilter.length} selected`
}, [sizeFilter])
const uploadedByDisplayLabel = useMemo(() => {
if (uploadedByFilter.length === 0) return 'All'
if (uploadedByFilter.length === 1)
return members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member'
return `${uploadedByFilter.length} members`
}, [uploadedByFilter, members])
const memberOptions: ComboboxOption[] = useMemo(
() =>
(members ?? []).map((m) => ({
value: m.userId,
label: m.name,
iconElement: m.image ? (
<img
src={m.image}
alt={m.name}
referrerPolicy='no-referrer'
className='h-[14px] w-[14px] rounded-full border border-[var(--border)] object-cover'
/>
) : (
<span className='flex h-[14px] w-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{m.name.charAt(0).toUpperCase()}
</span>
),
})),
[members]
)
const sortConfig: SortConfig = useMemo(
() => ({
options: [
{ id: 'name', label: 'Name' },
{ id: 'size', label: 'Size' },
{ id: 'type', label: 'Type' },
{ id: 'created', label: 'Created' },
{ id: 'owner', label: 'Owner' },
],
active: activeSort,
onSort: (column, direction) => setActiveSort({ column, direction }),
onClear: () => setActiveSort(null),
}),
[activeSort]
)
const hasActiveFilters =
typeFilter.length > 0 || sizeFilter.length > 0 || uploadedByFilter.length > 0
const filterContent = useMemo(
() => (
<div className='flex w-[240px] flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>File Type</span>
<Combobox
options={[
{ value: 'document', label: 'Documents' },
{ value: 'audio', label: 'Audio' },
{ value: 'video', label: 'Video' },
]}
multiSelect
multiSelectValues={typeFilter}
onMultiSelectChange={setTypeFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{typeDisplayLabel}</span>
}
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Size</span>
<Combobox
options={[
{ value: 'small', label: 'Small (< 1 MB)' },
{ value: 'medium', label: 'Medium (110 MB)' },
{ value: 'large', label: 'Large (> 10 MB)' },
]}
multiSelect
multiSelectValues={sizeFilter}
onMultiSelectChange={setSizeFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{sizeDisplayLabel}</span>
}
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
{memberOptions.length > 0 && (
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
Uploaded By
</span>
<Combobox
options={memberOptions}
multiSelect
multiSelectValues={uploadedByFilter}
onMultiSelectChange={setUploadedByFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>
{uploadedByDisplayLabel}
</span>
}
searchable
searchPlaceholder='Search members...'
showAllOption
allOptionLabel='All'
size='sm'
className='h-[32px] w-full rounded-md'
/>
</div>
)}
{hasActiveFilters && (
<button
type='button'
onClick={() => {
setTypeFilter([])
setSizeFilter([])
setUploadedByFilter([])
}}
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
>
Clear all filters
</button>
)}
</div>
),
[
typeFilter,
sizeFilter,
uploadedByFilter,
memberOptions,
typeDisplayLabel,
sizeDisplayLabel,
uploadedByDisplayLabel,
hasActiveFilters,
]
)
const filterTags: FilterTag[] = useMemo(() => {
const tags: FilterTag[] = []
if (typeFilter.length > 0) {
const typeLabels: Record<string, string> = {
document: 'Documents',
audio: 'Audio',
video: 'Video',
}
const label =
typeFilter.length === 1
? `Type: ${typeLabels[typeFilter[0]]}`
: `Type: ${typeFilter.length} selected`
tags.push({ label, onRemove: () => setTypeFilter([]) })
}
if (sizeFilter.length > 0) {
const sizeLabels: Record<string, string> = {
small: 'Small',
medium: 'Medium',
large: 'Large',
}
const label =
sizeFilter.length === 1
? `Size: ${sizeLabels[sizeFilter[0]]}`
: `Size: ${sizeFilter.length} selected`
tags.push({ label, onRemove: () => setSizeFilter([]) })
}
if (uploadedByFilter.length > 0) {
const label =
uploadedByFilter.length === 1
? `Uploaded by: ${members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member'}`
: `Uploaded by: ${uploadedByFilter.length} members`
tags.push({ label, onRemove: () => setUploadedByFilter([]) })
}
return tags
}, [typeFilter, sizeFilter, uploadedByFilter, members])
if (fileIdFromRoute && !selectedFile) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
@@ -1077,9 +834,7 @@ export function Files() {
title='Files'
create={createConfig}
search={searchConfig}
sort={sortConfig}
filter={filterContent}
filterTags={filterTags}
defaultSort='created'
headerActions={headerActionsConfig}
columns={COLUMNS}
rows={rows}

View File

@@ -91,9 +91,7 @@ export function MothershipChat({
}: MothershipChatProps) {
const styles = LAYOUT_STYLES[layout]
const isStreamActive = isSending || isReconnecting
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive, {
scrollOnMount: true,
})
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive)
const hasMessages = messages.length > 0
const initialScrollDoneRef = useRef(false)

View File

@@ -52,7 +52,7 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) {
return (
<>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
className='mr-[0px] h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
@@ -72,16 +72,7 @@ function FileDropdownItem({ item }: DropdownItemRenderProps) {
const DocIcon = getDocumentIcon('', item.name)
return (
<>
<DocIcon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
</>
)
}
function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon: ElementType }) {
return (
<>
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<DocIcon className='mr-2 h-[14px] w-[14px] text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
</>
)
@@ -113,7 +104,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
renderTabIcon: (_resource, className) => (
<TableIcon className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={TableIcon} />,
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
},
file: {
type: 'file',
@@ -132,7 +123,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
renderTabIcon: (_resource, className) => (
<Database className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Database} />,
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
},
} as const

Some files were not shown because too many files have changed in this diff Show More