mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
73 Commits
feat/paste
...
feat/googl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a51c8f184 | ||
|
|
fac5f63bd1 | ||
|
|
369289497e | ||
|
|
b004cba88a | ||
|
|
60a6745336 | ||
|
|
8b411d4c27 | ||
|
|
c6ab7962a4 | ||
|
|
8b14ba1dfe | ||
|
|
c3328e0c08 | ||
|
|
446de8f459 | ||
|
|
9e1d103ee3 | ||
|
|
ab50c2d246 | ||
|
|
f7b5055dfa | ||
|
|
8f0efca279 | ||
|
|
76e2235427 | ||
|
|
748f0dd8b8 | ||
|
|
6ee02a85ad | ||
|
|
48c417f4b1 | ||
|
|
54683d7971 | ||
|
|
6241ca909b | ||
|
|
299998c25a | ||
|
|
b32a3884c7 | ||
|
|
4544fd4519 | ||
|
|
019630bdc8 | ||
|
|
90f592797a | ||
|
|
d091441e39 | ||
|
|
7d4dd26760 | ||
|
|
0abeac77e1 | ||
|
|
e9c94fa462 | ||
|
|
72eea64bf6 | ||
|
|
27460f847c | ||
|
|
c7643198dc | ||
|
|
e5aef6184a | ||
|
|
4ae5b1b620 | ||
|
|
5c334874eb | ||
|
|
d3d58a9615 | ||
|
|
1d59eca90a | ||
|
|
e1359b09d6 | ||
|
|
336c3ef852 | ||
|
|
ce345b9112 | ||
|
|
370148a4d8 | ||
|
|
35b3646330 | ||
|
|
5c47ea58f8 | ||
|
|
c4f4e6b48c | ||
|
|
1728c370de | ||
|
|
82e58a5082 | ||
|
|
336c065234 | ||
|
|
b3713642b2 | ||
|
|
b9b930bb63 | ||
|
|
f1ead2ed55 | ||
|
|
e8717bb5c0 | ||
|
|
30377d775b | ||
|
|
d013132d0e | ||
|
|
7b0ce8064a | ||
|
|
0ea73263df | ||
|
|
edc502384b | ||
|
|
7ec025973e | ||
|
|
e2be99263c | ||
|
|
f6b461ad47 | ||
|
|
e4d35735b1 | ||
|
|
b4064c57fb | ||
|
|
eac41ca105 | ||
|
|
d2c3c1c39e | ||
|
|
8f3e864751 | ||
|
|
23c3072784 | ||
|
|
33fdb11396 | ||
|
|
21156dd54a | ||
|
|
c05e2e0fc8 | ||
|
|
a7c1e510e6 | ||
|
|
e0da2852bd | ||
|
|
271624a402 | ||
|
|
dda012eae9 | ||
|
|
2dd6d3d1e6 |
10
README.md
10
README.md
@@ -74,6 +74,10 @@ 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
|
||||
@@ -113,10 +117,12 @@ cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
|
||||
5. Start development servers:
|
||||
|
||||
```bash
|
||||
bun run dev:full # Starts both Next.js app and realtime socket server
|
||||
bun run dev:full # Starts Next.js app, realtime socket server, and the BullMQ worker
|
||||
```
|
||||
|
||||
Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime).
|
||||
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).
|
||||
|
||||
## Copilot API Keys
|
||||
|
||||
|
||||
@@ -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',
|
||||
template: '%s | Sim Docs',
|
||||
},
|
||||
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.',
|
||||
|
||||
@@ -1285,6 +1285,17 @@ 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
|
||||
|
||||
@@ -126,6 +126,7 @@ import {
|
||||
PolymarketIcon,
|
||||
PostgresIcon,
|
||||
PosthogIcon,
|
||||
ProfoundIcon,
|
||||
PulseIcon,
|
||||
QdrantIcon,
|
||||
QuiverIcon,
|
||||
@@ -302,6 +303,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
polymarket: PolymarketIcon,
|
||||
postgresql: PostgresIcon,
|
||||
posthog: PosthogIcon,
|
||||
profound: ProfoundIcon,
|
||||
pulse_v2: PulseIcon,
|
||||
qdrant: QdrantIcon,
|
||||
quiver: QuiverIcon,
|
||||
|
||||
206
apps/docs/content/docs/en/credentials/google-service-account.mdx
Normal file
206
apps/docs/content/docs/en/credentials/google-service-account.mdx
Normal file
@@ -0,0 +1,206 @@
|
||||
---
|
||||
title: Google Service Accounts
|
||||
description: Set up Google service accounts with domain-wide delegation for Gmail, Sheets, Drive, Calendar, and other Google services
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps'
|
||||
import { Image } from '@/components/ui/image'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
|
||||
Google service accounts with domain-wide delegation let your workflows access Google APIs on behalf of users in your Google Workspace domain — without requiring each user to complete an OAuth consent flow. This is ideal for automated workflows that need to send emails, read spreadsheets, or manage files across your organization.
|
||||
|
||||
For example, you could build a workflow that iterates through a list of employees, impersonates each one to read their Google Docs, and uploads the contents to a shared knowledge base — all without requiring any of those users to sign in.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before adding a service account to Sim, you need to configure it in the Google Cloud Console and Google Workspace Admin Console.
|
||||
|
||||
### 1. Create a Service Account in Google Cloud
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
Go to the [Google Cloud Console](https://console.cloud.google.com/) and select your project (or create one)
|
||||
</Step>
|
||||
<Step>
|
||||
Navigate to **IAM & Admin** → **Service Accounts**
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Create Service Account**, give it a name and description, then click **Create and Continue**
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/gcp-create-service-account.png"
|
||||
alt="Google Cloud Console — Create service account form"
|
||||
width={700}
|
||||
height={500}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
<Step>
|
||||
Skip the optional role and user access steps and click **Done**
|
||||
</Step>
|
||||
<Step>
|
||||
Click on the newly created service account, go to the **Keys** tab, and click **Add Key** → **Create new key**
|
||||
</Step>
|
||||
<Step>
|
||||
Select **JSON** as the key type and click **Create**. A JSON key file will download — keep this safe
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/gcp-create-private-key.png"
|
||||
alt="Google Cloud Console — Create private key dialog with JSON selected"
|
||||
width={700}
|
||||
height={400}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="warn">
|
||||
The JSON key file contains your service account's private key. Treat it like a password — do not commit it to source control or share it publicly.
|
||||
</Callout>
|
||||
|
||||
### 2. Enable the Required APIs
|
||||
|
||||
In the Google Cloud Console, go to **APIs & Services** → **Library** and enable the APIs for the services your workflows will use. See the [scopes reference](#scopes-reference) below for the full list of APIs by service.
|
||||
|
||||
### 3. Set Up Domain-Wide Delegation
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
In the Google Cloud Console, go to **IAM & Admin** → **Service Accounts**, click on your service account, and copy the **Client ID** (the numeric ID, not the email)
|
||||
</Step>
|
||||
<Step>
|
||||
Open the [Google Workspace Admin Console](https://admin.google.com/) and navigate to **Security** → **Access and data control** → **API controls**
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Manage Domain Wide Delegation**, then click **Add new**
|
||||
</Step>
|
||||
<Step>
|
||||
Paste the **Client ID** from your service account, then add the OAuth scopes for the services your workflows need. Copy the full scope URLs from the [scopes reference](#scopes-reference) below — only authorize scopes for services you plan to use.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/gcp-add-client-id.png"
|
||||
alt="Google Workspace Admin Console — Add a new client ID with OAuth scopes"
|
||||
width={350}
|
||||
height={300}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Authorize**
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="info">
|
||||
Domain-wide delegation must be configured by a Google Workspace admin. If you are not an admin, send the Client ID and required scopes to your admin.
|
||||
</Callout>
|
||||
|
||||
### Scopes Reference
|
||||
|
||||
The table below lists every Google service that supports service account authentication in Sim, the API to enable in Google Cloud Console, and the delegation scopes to authorize. Copy the scope string for each service you need and paste it into the Google Workspace Admin Console.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="whitespace-nowrap">Service</th>
|
||||
<th className="whitespace-nowrap">API to Enable</th>
|
||||
<th>Delegation Scopes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Gmail</td><td>Gmail API</td><td><code>{'https://www.googleapis.com/auth/gmail.send'}</code><br/><code>{'https://www.googleapis.com/auth/gmail.modify'}</code><br/><code>{'https://www.googleapis.com/auth/gmail.labels'}</code></td></tr>
|
||||
<tr><td>Google Sheets</td><td>Google Sheets API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
|
||||
<tr><td>Google Drive</td><td>Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
|
||||
<tr><td>Google Docs</td><td>Google Docs API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
|
||||
<tr><td>Google Slides</td><td>Google Slides API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
|
||||
<tr><td>Google Forms</td><td>Google Forms API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/forms.body'}</code><br/><code>{'https://www.googleapis.com/auth/forms.responses.readonly'}</code></td></tr>
|
||||
<tr><td>Google Calendar</td><td>Google Calendar API</td><td><code>{'https://www.googleapis.com/auth/calendar'}</code></td></tr>
|
||||
<tr><td>Google Contacts</td><td>People API</td><td><code>{'https://www.googleapis.com/auth/contacts'}</code></td></tr>
|
||||
<tr><td>BigQuery</td><td>BigQuery API</td><td><code>{'https://www.googleapis.com/auth/bigquery'}</code></td></tr>
|
||||
<tr><td>Google Tasks</td><td>Tasks API</td><td><code>{'https://www.googleapis.com/auth/tasks'}</code></td></tr>
|
||||
<tr><td>Google Vault</td><td>Vault API, Cloud Storage API</td><td><code>{'https://www.googleapis.com/auth/ediscovery'}</code><br/><code>{'https://www.googleapis.com/auth/devstorage.read_only'}</code></td></tr>
|
||||
<tr><td>Google Groups</td><td>Admin SDK API</td><td><code>{'https://www.googleapis.com/auth/admin.directory.group'}</code><br/><code>{'https://www.googleapis.com/auth/admin.directory.group.member'}</code></td></tr>
|
||||
<tr><td>Google Meet</td><td>Google Meet API</td><td><code>{'https://www.googleapis.com/auth/meetings.space.created'}</code><br/><code>{'https://www.googleapis.com/auth/meetings.space.readonly'}</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Callout type="info">
|
||||
You only need to enable APIs and authorize scopes for the services you plan to use. When authorizing multiple services, combine their scope strings with commas into a single entry in the Admin Console.
|
||||
</Callout>
|
||||
|
||||
## Adding the Service Account to Sim
|
||||
|
||||
Once Google Cloud and Workspace are configured, add the service account as a credential in Sim.
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
Open your workspace **Settings** and go to the **Integrations** tab
|
||||
</Step>
|
||||
<Step>
|
||||
Search for "Google Service Account" and click **Connect**
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/integrations-service-account.png"
|
||||
alt="Integrations page showing Google Service Account"
|
||||
width={800}
|
||||
height={150}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
<Step>
|
||||
Paste the full contents of your JSON key file into the text area
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/add-service-account.png"
|
||||
alt="Add Google Service Account dialog"
|
||||
width={350}
|
||||
height={420}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
<Step>
|
||||
Give the credential a display name (the service account email is used by default)
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Save**
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
The JSON key file is validated for the required fields (`type`, `client_email`, `private_key`, `project_id`) and encrypted before being stored.
|
||||
|
||||
## Using Delegated Access in Workflows
|
||||
|
||||
When you use a Google block (Gmail, Sheets, Drive, etc.) in a workflow and select a service account credential, an **Impersonate User Email** field appears below the credential selector.
|
||||
|
||||
Enter the email address of the Google Workspace user you want the service account to act as. For example, if you enter `alice@yourcompany.com`, the workflow will send emails from Alice's account, read her spreadsheets, or access her calendar — depending on the scopes you authorized.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/workflow-impersonated-account.png"
|
||||
alt="Gmail block in a workflow showing the Impersonated Account field with a service account credential"
|
||||
width={800}
|
||||
height={350}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout type="warn">
|
||||
The impersonated email must belong to a user in the Google Workspace domain where you configured domain-wide delegation. Impersonating external email addresses will fail.
|
||||
</Callout>
|
||||
|
||||
<FAQ items={[
|
||||
{ question: "Can I use a service account without domain-wide delegation?", answer: "Yes, but it will only be able to access resources owned by the service account itself (e.g., spreadsheets shared directly with the service account email). Without delegation, you cannot impersonate users or access their personal data like Gmail." },
|
||||
{ question: "What happens if the impersonation email field is left blank?", answer: "The service account will authenticate as itself. This works for accessing shared resources (like a Google Sheet shared with the service account email) but will fail for user-specific APIs like Gmail." },
|
||||
{ question: "Can I use the same service account for multiple Google services?", answer: "Yes. A single service account can be used across Gmail, Sheets, Drive, Calendar, and other Google services — as long as the required API is enabled in Google Cloud and the corresponding scopes are authorized in the Workspace admin console." },
|
||||
{ question: "How do I rotate the service account key?", answer: "Create a new JSON key in the Google Cloud Console under your service account's Keys tab, then update the credential in Sim with the new key. Delete the old key from Google Cloud once the new one is working." },
|
||||
{ question: "Does the impersonated user need a Google Workspace license?", answer: "Yes. Domain-wide delegation only works with users who have a Google Workspace account in the domain. Consumer Gmail accounts (e.g., @gmail.com) cannot be impersonated." },
|
||||
]} />
|
||||
5
apps/docs/content/docs/en/credentials/meta.json
Normal file
5
apps/docs/content/docs/en/credentials/meta.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Credentials",
|
||||
"pages": ["index", "google-service-account"],
|
||||
"defaultOpen": false
|
||||
}
|
||||
@@ -195,6 +195,17 @@ By default, your usage is capped at the credits included in your plan. To allow
|
||||
|
||||
Max (individual) shares the same rate limits as team plans. Team plans (Pro or Max for Teams) use the Max-tier rate limits.
|
||||
|
||||
### Concurrent Execution Limits
|
||||
|
||||
| Plan | Concurrent Executions |
|
||||
|------|----------------------|
|
||||
| **Free** | 5 |
|
||||
| **Pro** | 50 |
|
||||
| **Max / Team** | 200 |
|
||||
| **Enterprise** | 200 (customizable) |
|
||||
|
||||
Concurrent execution limits control how many workflow executions can run simultaneously within a workspace. When the limit is reached, new executions are queued and admitted as running executions complete. Manual runs from the editor are not subject to these limits.
|
||||
|
||||
### File Storage
|
||||
|
||||
| Plan | Storage |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: File
|
||||
description: Read and parse multiple files
|
||||
description: Read and write workspace 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
|
||||
|
||||
Upload files directly or import from external URLs to get UserFile objects for use in other blocks.
|
||||
Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.
|
||||
|
||||
|
||||
|
||||
@@ -52,4 +52,45 @@ 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 |
|
||||
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"polymarket",
|
||||
"postgresql",
|
||||
"posthog",
|
||||
"profound",
|
||||
"pulse",
|
||||
"qdrant",
|
||||
"quiver",
|
||||
|
||||
626
apps/docs/content/docs/en/tools/profound.mdx
Normal file
626
apps/docs/content/docs/en/tools/profound.mdx
Normal file
@@ -0,0 +1,626 @@
|
||||
---
|
||||
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 |
|
||||
|
||||
|
||||
BIN
apps/docs/public/static/credentials/add-service-account.png
Normal file
BIN
apps/docs/public/static/credentials/add-service-account.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/docs/public/static/credentials/gcp-add-client-id.png
Normal file
BIN
apps/docs/public/static/credentials/gcp-add-client-id.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
apps/docs/public/static/credentials/gcp-create-private-key.png
Normal file
BIN
apps/docs/public/static/credentials/gcp-create-private-key.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -1,3 +1,6 @@
|
||||
/** 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
|
||||
/** 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
|
||||
|
||||
@@ -288,7 +288,6 @@ 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'>
|
||||
|
||||
@@ -81,6 +81,56 @@ 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' })
|
||||
@@ -97,39 +147,25 @@ export function AccessControlPanel() {
|
||||
|
||||
return (
|
||||
<div key={category.label} className={catIdx > 0 ? 'mt-4' : ''}>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/55 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]
|
||||
|
||||
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>
|
||||
)
|
||||
})}
|
||||
{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] }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -140,12 +176,11 @@ 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]/30 text-[10px] uppercase leading-none tracking-[0.08em]'>
|
||||
<span className='font-[430] font-season text-[#F6F6F6]/55 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,
|
||||
@@ -153,30 +188,19 @@ export function AccessControlPanel() {
|
||||
) + featIdx
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<FeatureToggleItem
|
||||
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.1 + currentIndex * 0.04,
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
}}
|
||||
onClick={() =>
|
||||
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={() =>
|
||||
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>
|
||||
|
||||
@@ -146,14 +146,14 @@ function AuditRow({ entry, index }: AuditRowProps) {
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/30 text-[11px] leading-none tracking-[0.02em]'>
|
||||
<span className='w-[56px] shrink-0 font-[430] font-season text-[#F6F6F6]/55 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]/40'> · </span>
|
||||
<span className='text-[#F6F6F6]/60'> · </span>
|
||||
<span className='text-[#F6F6F6]/55'>{entry.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -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)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
|
||||
<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)]'>
|
||||
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)_30%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)]'>
|
||||
<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)]'>
|
||||
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)_30%,transparent)] text-xs leading-none tracking-[0.02em]'>
|
||||
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,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]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
<p className='mt-2 max-w-[480px] font-[430] font-season text-[#F6F6F6]/70 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]/50 text-[14px] leading-[150%] tracking-[0.02em]'>
|
||||
<p className='mt-1.5 font-[430] font-season text-[#F6F6F6]/70 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)_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)]'
|
||||
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)]'
|
||||
>
|
||||
{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)_40%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-base leading-[150%] tracking-[0.02em]'>
|
||||
Ready for growth?
|
||||
</p>
|
||||
<DemoRequestModal>
|
||||
|
||||
@@ -190,7 +190,6 @@ export default function Features() {
|
||||
width={1440}
|
||||
height={366}
|
||||
className='h-auto w-full'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ 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',
|
||||
|
||||
@@ -26,6 +26,8 @@ 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' },
|
||||
]
|
||||
|
||||
@@ -25,6 +25,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'5GB file storage',
|
||||
'3 tables · 1,000 rows each',
|
||||
'5 min execution limit',
|
||||
'5 concurrent/workspace',
|
||||
'7-day log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
@@ -42,6 +43,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'50GB file storage',
|
||||
'25 tables · 5,000 rows each',
|
||||
'50 min execution · 150 runs/min',
|
||||
'50 concurrent/workspace',
|
||||
'Unlimited log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
@@ -59,6 +61,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'500GB file storage',
|
||||
'25 tables · 5,000 rows each',
|
||||
'50 min execution · 300 runs/min',
|
||||
'200 concurrent/workspace',
|
||||
'Unlimited log retention',
|
||||
'CLI/SDK/MCP Access',
|
||||
],
|
||||
@@ -75,6 +78,7 @@ const PRICING_TIERS: PricingTier[] = [
|
||||
'Custom file storage',
|
||||
'10,000 tables · 1M rows each',
|
||||
'Custom execution limits',
|
||||
'Custom concurrency limits',
|
||||
'Unlimited log retention',
|
||||
'SSO & SCIM · SOC2 & HIPAA',
|
||||
'Self hosting · Dedicated support',
|
||||
|
||||
43
apps/sim/app/(landing)/blog/components/blog-image.tsx
Normal file
43
apps/sim/app/(landing)/blog/components/blog-image.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
62
apps/sim/app/(landing)/blog/components/lightbox.tsx
Normal file
62
apps/sim/app/(landing)/blog/components/lightbox.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -126,6 +126,7 @@ import {
|
||||
PolymarketIcon,
|
||||
PostgresIcon,
|
||||
PosthogIcon,
|
||||
ProfoundIcon,
|
||||
PulseIcon,
|
||||
QdrantIcon,
|
||||
QuiverIcon,
|
||||
@@ -302,6 +303,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
polymarket: PolymarketIcon,
|
||||
postgresql: PostgresIcon,
|
||||
posthog: PosthogIcon,
|
||||
profound: ProfoundIcon,
|
||||
pulse_v2: PulseIcon,
|
||||
qdrant: QdrantIcon,
|
||||
quiver: QuiverIcon,
|
||||
|
||||
@@ -2993,13 +2993,26 @@
|
||||
"type": "file_v3",
|
||||
"slug": "file",
|
||||
"name": "File",
|
||||
"description": "Read and parse multiple files",
|
||||
"longDescription": "Upload files directly or import from external URLs to get UserFile objects for use in other blocks.",
|
||||
"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.",
|
||||
"bgColor": "#40916C",
|
||||
"iconName": "DocumentIcon",
|
||||
"docsUrl": "https://docs.sim.ai/tools/file",
|
||||
"operations": [],
|
||||
"operationCount": 0,
|
||||
"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,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "none",
|
||||
@@ -8611,6 +8624,121 @@
|
||||
"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",
|
||||
|
||||
292
apps/sim/app/(landing)/partners/page.tsx
Normal file
292
apps/sim/app/(landing)/partners/page.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,10 @@ 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'
|
||||
@@ -32,7 +36,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
forcedTheme={isLightModePage ? 'light' : undefined}
|
||||
forcedTheme={forcedTheme}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
--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 */
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
68
apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx
Normal file
68
apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
16
apps/sim/app/academy/(catalog)/layout.tsx
Normal file
16
apps/sim/app/academy/(catalog)/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
19
apps/sim/app/academy/(catalog)/not-found.tsx
Normal file
19
apps/sim/app/academy/(catalog)/not-found.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
76
apps/sim/app/academy/(catalog)/page.tsx
Normal file
76
apps/sim/app/academy/(catalog)/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
23
apps/sim/app/academy/[courseSlug]/[lessonSlug]/layout.tsx
Normal file
23
apps/sim/app/academy/[courseSlug]/[lessonSlug]/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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}</>
|
||||
}
|
||||
219
apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx
Normal file
219
apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
56
apps/sim/app/academy/components/lesson-video.tsx
Normal file
56
apps/sim/app/academy/components/lesson-video.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'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
|
||||
}
|
||||
}
|
||||
432
apps/sim/app/academy/components/sandbox-canvas-provider.tsx
Normal file
432
apps/sim/app/academy/components/sandbox-canvas-provider.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
50
apps/sim/app/academy/components/validation-checklist.tsx
Normal file
50
apps/sim/app/academy/components/validation-checklist.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
33
apps/sim/app/academy/layout.tsx
Normal file
33
apps/sim/app/academy/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
215
apps/sim/app/api/academy/certificates/route.ts
Normal file
215
apps/sim/app/api/academy/certificates/route.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
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()}`
|
||||
}
|
||||
@@ -7,7 +7,10 @@ import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
|
||||
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
getServiceAccountProviderForProviderId,
|
||||
} from '@/lib/oauth/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
@@ -36,7 +39,8 @@ function toCredentialResponse(
|
||||
displayName: string,
|
||||
providerId: string,
|
||||
updatedAt: Date,
|
||||
scope: string | null
|
||||
scope: string | null,
|
||||
credentialType: 'oauth' | 'service_account' = 'oauth'
|
||||
) {
|
||||
const storedScope = scope?.trim()
|
||||
// Some providers (e.g. Box) don't return scopes in their token response,
|
||||
@@ -52,6 +56,7 @@ function toCredentialResponse(
|
||||
id,
|
||||
name: displayName,
|
||||
provider: providerId,
|
||||
type: credentialType,
|
||||
lastUsed: updatedAt.toISOString(),
|
||||
isDefault: featureType === 'default',
|
||||
scopes,
|
||||
@@ -149,6 +154,7 @@ export async function GET(request: NextRequest) {
|
||||
displayName: credential.displayName,
|
||||
providerId: credential.providerId,
|
||||
accountId: credential.accountId,
|
||||
updatedAt: credential.updatedAt,
|
||||
accountProviderId: account.providerId,
|
||||
accountScope: account.scope,
|
||||
accountUpdatedAt: account.updatedAt,
|
||||
@@ -159,6 +165,49 @@ export async function GET(request: NextRequest) {
|
||||
.limit(1)
|
||||
|
||||
if (platformCredential) {
|
||||
if (platformCredential.type === 'service_account') {
|
||||
if (
|
||||
workflowId &&
|
||||
(!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId)
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!workflowId) {
|
||||
const [membership] = await db
|
||||
.select({ id: credentialMember.id })
|
||||
.from(credentialMember)
|
||||
.where(
|
||||
and(
|
||||
eq(credentialMember.credentialId, platformCredential.id),
|
||||
eq(credentialMember.userId, requesterUserId),
|
||||
eq(credentialMember.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
credentials: [
|
||||
toCredentialResponse(
|
||||
platformCredential.id,
|
||||
platformCredential.displayName,
|
||||
platformCredential.providerId || 'google-service-account',
|
||||
platformCredential.updatedAt,
|
||||
null,
|
||||
'service_account'
|
||||
),
|
||||
],
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
|
||||
return NextResponse.json({ credentials: [] }, { status: 200 })
|
||||
}
|
||||
@@ -238,14 +287,52 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
credentials: credentialsData.map((row) =>
|
||||
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
|
||||
),
|
||||
},
|
||||
{ status: 200 }
|
||||
const results = credentialsData.map((row) =>
|
||||
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
|
||||
)
|
||||
|
||||
const saProviderId = getServiceAccountProviderForProviderId(providerParam)
|
||||
|
||||
if (saProviderId) {
|
||||
const serviceAccountCreds = await db
|
||||
.select({
|
||||
id: credential.id,
|
||||
displayName: credential.displayName,
|
||||
providerId: credential.providerId,
|
||||
updatedAt: credential.updatedAt,
|
||||
})
|
||||
.from(credential)
|
||||
.innerJoin(
|
||||
credentialMember,
|
||||
and(
|
||||
eq(credentialMember.credentialId, credential.id),
|
||||
eq(credentialMember.userId, requesterUserId),
|
||||
eq(credentialMember.status, 'active')
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, effectiveWorkspaceId),
|
||||
eq(credential.type, 'service_account'),
|
||||
eq(credential.providerId, saProviderId)
|
||||
)
|
||||
)
|
||||
|
||||
for (const sa of serviceAccountCreds) {
|
||||
results.push(
|
||||
toCredentialResponse(
|
||||
sa.id,
|
||||
sa.displayName,
|
||||
sa.providerId || saProviderId,
|
||||
sa.updatedAt,
|
||||
null,
|
||||
'service_account'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ credentials: results }, { status: 200 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ credentials: [] }, { status: 200 })
|
||||
|
||||
@@ -11,6 +11,8 @@ const {
|
||||
mockGetCredential,
|
||||
mockRefreshTokenIfNeeded,
|
||||
mockGetOAuthToken,
|
||||
mockResolveOAuthAccountId,
|
||||
mockGetServiceAccountToken,
|
||||
mockAuthorizeCredentialUse,
|
||||
mockCheckSessionOrInternalAuth,
|
||||
mockLogger,
|
||||
@@ -29,6 +31,8 @@ const {
|
||||
mockGetCredential: vi.fn(),
|
||||
mockRefreshTokenIfNeeded: vi.fn(),
|
||||
mockGetOAuthToken: vi.fn(),
|
||||
mockResolveOAuthAccountId: vi.fn(),
|
||||
mockGetServiceAccountToken: vi.fn(),
|
||||
mockAuthorizeCredentialUse: vi.fn(),
|
||||
mockCheckSessionOrInternalAuth: vi.fn(),
|
||||
mockLogger: logger,
|
||||
@@ -40,6 +44,8 @@ vi.mock('@/app/api/auth/oauth/utils', () => ({
|
||||
getCredential: mockGetCredential,
|
||||
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
|
||||
getOAuthToken: mockGetOAuthToken,
|
||||
resolveOAuthAccountId: mockResolveOAuthAccountId,
|
||||
getServiceAccountToken: mockGetServiceAccountToken,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
@@ -50,6 +56,10 @@ vi.mock('@/lib/auth/credential-access', () => ({
|
||||
authorizeCredentialUse: mockAuthorizeCredentialUse,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkHybridAuth: vi.fn(),
|
||||
@@ -62,6 +72,7 @@ import { GET, POST } from '@/app/api/auth/oauth/token/route'
|
||||
describe('OAuth Token API Routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolveOAuthAccountId.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,13 @@ import { z } from 'zod'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import {
|
||||
getCredential,
|
||||
getOAuthToken,
|
||||
getServiceAccountToken,
|
||||
refreshTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -18,6 +24,8 @@ const tokenRequestSchema = z
|
||||
credentialAccountUserId: z.string().min(1).optional(),
|
||||
providerId: z.string().min(1).optional(),
|
||||
workflowId: z.string().min(1).nullish(),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
impersonateEmail: z.string().email().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => data.credentialId || (data.credentialAccountUserId && data.providerId),
|
||||
@@ -63,7 +71,14 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data
|
||||
const {
|
||||
credentialId,
|
||||
credentialAccountUserId,
|
||||
providerId,
|
||||
workflowId,
|
||||
scopes,
|
||||
impersonateEmail,
|
||||
} = parseResult.data
|
||||
|
||||
if (credentialAccountUserId && providerId) {
|
||||
logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
|
||||
@@ -112,6 +127,31 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const callerUserId = new URL(request.url).searchParams.get('userId') || undefined
|
||||
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (resolved?.credentialType === 'service_account' && resolved.credentialId) {
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
workflowId: workflowId ?? undefined,
|
||||
requireWorkflowIdForInternal: false,
|
||||
callerUserId,
|
||||
})
|
||||
if (!authz.ok) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getServiceAccountToken(
|
||||
resolved.credentialId,
|
||||
scopes ?? [],
|
||||
impersonateEmail
|
||||
)
|
||||
return NextResponse.json({ accessToken }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Service account token error:`, error)
|
||||
return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
workflowId: workflowId ?? undefined,
|
||||
|
||||
@@ -160,7 +160,12 @@ describe('OAuth Utils', () => {
|
||||
|
||||
describe('refreshAccessTokenIfNeeded', () => {
|
||||
it('should return valid access token without refresh if not expired', async () => {
|
||||
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
|
||||
const mockResolvedCredential = {
|
||||
id: 'credential-id',
|
||||
type: 'oauth',
|
||||
accountId: 'account-id',
|
||||
workspaceId: 'workspace-id',
|
||||
}
|
||||
const mockAccountRow = {
|
||||
id: 'account-id',
|
||||
accessToken: 'valid-token',
|
||||
@@ -169,7 +174,7 @@ describe('OAuth Utils', () => {
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockSelectChain([mockCredentialRow])
|
||||
mockSelectChain([mockResolvedCredential])
|
||||
mockSelectChain([mockAccountRow])
|
||||
|
||||
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
||||
@@ -179,7 +184,12 @@ describe('OAuth Utils', () => {
|
||||
})
|
||||
|
||||
it('should refresh token when expired', async () => {
|
||||
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
|
||||
const mockResolvedCredential = {
|
||||
id: 'credential-id',
|
||||
type: 'oauth',
|
||||
accountId: 'account-id',
|
||||
workspaceId: 'workspace-id',
|
||||
}
|
||||
const mockAccountRow = {
|
||||
id: 'account-id',
|
||||
accessToken: 'expired-token',
|
||||
@@ -188,7 +198,7 @@ describe('OAuth Utils', () => {
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockSelectChain([mockCredentialRow])
|
||||
mockSelectChain([mockResolvedCredential])
|
||||
mockSelectChain([mockAccountRow])
|
||||
mockUpdateChain()
|
||||
|
||||
@@ -215,7 +225,12 @@ describe('OAuth Utils', () => {
|
||||
})
|
||||
|
||||
it('should return null if refresh fails', async () => {
|
||||
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
|
||||
const mockResolvedCredential = {
|
||||
id: 'credential-id',
|
||||
type: 'oauth',
|
||||
accountId: 'account-id',
|
||||
workspaceId: 'workspace-id',
|
||||
}
|
||||
const mockAccountRow = {
|
||||
id: 'account-id',
|
||||
accessToken: 'expired-token',
|
||||
@@ -224,7 +239,7 @@ describe('OAuth Utils', () => {
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockSelectChain([mockCredentialRow])
|
||||
mockSelectChain([mockResolvedCredential])
|
||||
mockSelectChain([mockAccountRow])
|
||||
|
||||
mockRefreshOAuthToken.mockResolvedValueOnce(null)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createSign } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { account, credential, credentialSetMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { refreshOAuthToken } from '@/lib/oauth'
|
||||
import {
|
||||
getMicrosoftRefreshTokenExpiry,
|
||||
@@ -11,6 +13,16 @@ import {
|
||||
|
||||
const logger = createLogger('OAuthUtilsAPI')
|
||||
|
||||
export class ServiceAccountTokenError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
public readonly errorDescription: string
|
||||
) {
|
||||
super(errorDescription)
|
||||
this.name = 'ServiceAccountTokenError'
|
||||
}
|
||||
}
|
||||
|
||||
interface AccountInsertData {
|
||||
id: string
|
||||
userId: string
|
||||
@@ -25,16 +37,26 @@ interface AccountInsertData {
|
||||
accessTokenExpiresAt?: Date
|
||||
}
|
||||
|
||||
export interface ResolvedCredential {
|
||||
accountId: string
|
||||
workspaceId?: string
|
||||
usedCredentialTable: boolean
|
||||
credentialType?: string
|
||||
credentialId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a credential ID to its underlying account ID.
|
||||
* If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`.
|
||||
* For service_account credentials, returns credentialId and type instead of accountId.
|
||||
* Otherwise assumes `credentialId` is already a raw `account.id` (legacy).
|
||||
*/
|
||||
export async function resolveOAuthAccountId(
|
||||
credentialId: string
|
||||
): Promise<{ accountId: string; workspaceId?: string; usedCredentialTable: boolean } | null> {
|
||||
): Promise<ResolvedCredential | null> {
|
||||
const [credentialRow] = await db
|
||||
.select({
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
accountId: credential.accountId,
|
||||
workspaceId: credential.workspaceId,
|
||||
@@ -44,6 +66,16 @@ export async function resolveOAuthAccountId(
|
||||
.limit(1)
|
||||
|
||||
if (credentialRow) {
|
||||
if (credentialRow.type === 'service_account') {
|
||||
return {
|
||||
accountId: '',
|
||||
credentialId: credentialRow.id,
|
||||
credentialType: 'service_account',
|
||||
workspaceId: credentialRow.workspaceId,
|
||||
usedCredentialTable: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
|
||||
return null
|
||||
}
|
||||
@@ -57,6 +89,124 @@ export async function resolveOAuthAccountId(
|
||||
return { accountId: credentialId, usedCredentialTable: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Userinfo scopes are excluded because service accounts don't represent a user
|
||||
* and cannot request user identity information. Google rejects token requests
|
||||
* that include these scopes for service account credentials.
|
||||
*/
|
||||
const SA_EXCLUDED_SCOPES = new Set([
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
])
|
||||
|
||||
/**
|
||||
* Generates a short-lived access token for a Google service account credential
|
||||
* using the two-legged OAuth JWT flow (RFC 7523).
|
||||
*
|
||||
* @param impersonateEmail - Optional. Required for Google Workspace APIs (Gmail, Drive, Calendar, etc.)
|
||||
* where the service account must impersonate a domain user via domain-wide delegation.
|
||||
* Not needed for project-scoped APIs like BigQuery or Vertex AI where the service account
|
||||
* authenticates directly with its own IAM permissions.
|
||||
*/
|
||||
export async function getServiceAccountToken(
|
||||
credentialId: string,
|
||||
scopes: string[],
|
||||
impersonateEmail?: string
|
||||
): Promise<string> {
|
||||
const [credentialRow] = await db
|
||||
.select({
|
||||
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
|
||||
})
|
||||
.from(credential)
|
||||
.where(eq(credential.id, credentialId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentialRow?.encryptedServiceAccountKey) {
|
||||
throw new Error('Service account key not found')
|
||||
}
|
||||
|
||||
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
|
||||
const keyData = JSON.parse(decrypted) as {
|
||||
client_email: string
|
||||
private_key: string
|
||||
token_uri?: string
|
||||
}
|
||||
|
||||
const filteredScopes = scopes.filter((s) => !SA_EXCLUDED_SCOPES.has(s))
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const ALLOWED_TOKEN_URIS = new Set(['https://oauth2.googleapis.com/token'])
|
||||
const tokenUri =
|
||||
keyData.token_uri && ALLOWED_TOKEN_URIS.has(keyData.token_uri)
|
||||
? keyData.token_uri
|
||||
: 'https://oauth2.googleapis.com/token'
|
||||
|
||||
const header = { alg: 'RS256', typ: 'JWT' }
|
||||
const payload: Record<string, unknown> = {
|
||||
iss: keyData.client_email,
|
||||
scope: filteredScopes.join(' '),
|
||||
aud: tokenUri,
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
}
|
||||
|
||||
if (impersonateEmail) {
|
||||
payload.sub = impersonateEmail
|
||||
}
|
||||
|
||||
logger.info('Service account JWT payload', {
|
||||
iss: keyData.client_email,
|
||||
sub: impersonateEmail || '(none)',
|
||||
scopes: filteredScopes.join(' '),
|
||||
aud: tokenUri,
|
||||
})
|
||||
|
||||
const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url')
|
||||
|
||||
const signingInput = `${toBase64Url(header)}.${toBase64Url(payload)}`
|
||||
|
||||
const signer = createSign('RSA-SHA256')
|
||||
signer.update(signingInput)
|
||||
const signature = signer.sign(keyData.private_key, 'base64url')
|
||||
|
||||
const jwt = `${signingInput}.${signature}`
|
||||
|
||||
const response = await fetch(tokenUri, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: jwt,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text()
|
||||
logger.error('Service account token exchange failed', {
|
||||
status: response.status,
|
||||
body: errorBody,
|
||||
})
|
||||
let description = `Token exchange failed: ${response.status}`
|
||||
try {
|
||||
const parsed = JSON.parse(errorBody) as { error_description?: string }
|
||||
if (parsed.error_description) {
|
||||
const raw = parsed.error_description
|
||||
if (raw.includes('SignatureException') || raw.includes('Invalid signature')) {
|
||||
description = 'Invalid account credentials.'
|
||||
} else {
|
||||
description = raw
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// use default description
|
||||
}
|
||||
throw new ServiceAccountTokenError(response.status, description)
|
||||
}
|
||||
|
||||
const tokenData = (await response.json()) as { access_token: string }
|
||||
return tokenData.access_token
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely inserts an account record, handling duplicate constraint violations gracefully.
|
||||
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
|
||||
@@ -81,19 +231,13 @@ export async function safeAccountInsert(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a credential by ID and verify it belongs to the user
|
||||
* Get a credential by resolved account ID and verify it belongs to the user.
|
||||
*/
|
||||
export async function getCredential(requestId: string, credentialId: string, userId: string) {
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function getCredentialByAccountId(requestId: string, accountId: string, userId: string) {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(and(eq(account.id, resolved.accountId), eq(account.userId, userId)))
|
||||
.where(and(eq(account.id, accountId), eq(account.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
@@ -103,10 +247,22 @@ export async function getCredential(requestId: string, credentialId: string, use
|
||||
|
||||
return {
|
||||
...credentials[0],
|
||||
resolvedCredentialId: resolved.accountId,
|
||||
resolvedCredentialId: accountId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a credential by ID and verify it belongs to the user.
|
||||
*/
|
||||
export async function getCredential(requestId: string, credentialId: string, userId: string) {
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
|
||||
return undefined
|
||||
}
|
||||
return getCredentialByAccountId(requestId, resolved.accountId, userId)
|
||||
}
|
||||
|
||||
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
|
||||
const connections = await db
|
||||
.select({
|
||||
@@ -196,19 +352,36 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes an OAuth token if needed based on credential information
|
||||
* Refreshes an OAuth token if needed based on credential information.
|
||||
* Also handles service account credentials by generating a JWT-based token.
|
||||
* @param credentialId The ID of the credential to check and potentially refresh
|
||||
* @param userId The user ID who owns the credential (for security verification)
|
||||
* @param requestId Request ID for log correlation
|
||||
* @param scopes Optional scopes for service account token generation
|
||||
* @returns The valid access token or null if refresh fails
|
||||
*/
|
||||
export async function refreshAccessTokenIfNeeded(
|
||||
credentialId: string,
|
||||
userId: string,
|
||||
requestId: string
|
||||
requestId: string,
|
||||
scopes?: string[],
|
||||
impersonateEmail?: string
|
||||
): Promise<string | null> {
|
||||
// Get the credential directly using the getCredential helper
|
||||
const credential = await getCredential(requestId, credentialId, userId)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
|
||||
if (!scopes?.length) {
|
||||
throw new Error('Scopes are required for service account credentials')
|
||||
}
|
||||
logger.info(`[${requestId}] Using service account token for credential`)
|
||||
return getServiceAccountToken(resolved.credentialId, scopes, impersonateEmail)
|
||||
}
|
||||
|
||||
// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query
|
||||
const credential = await getCredentialByAccountId(requestId, resolved.accountId, userId)
|
||||
|
||||
if (!credential) {
|
||||
return null
|
||||
|
||||
@@ -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,7 +66,6 @@ vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
},
|
||||
}))
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
@@ -88,6 +87,9 @@ 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' })),
|
||||
@@ -106,7 +108,7 @@ describe('Chat Edit API Route', () => {
|
||||
mockWhere.mockReturnValue({ limit: mockLimit })
|
||||
mockUpdate.mockReturnValue({ set: mockSet })
|
||||
mockSet.mockReturnValue({ where: mockWhere })
|
||||
mockDelete.mockReturnValue({ where: mockWhere })
|
||||
mockPerformChatUndeploy.mockResolvedValue({ success: true })
|
||||
|
||||
mockCreateSuccessResponse.mockImplementation((data) => {
|
||||
return new Response(JSON.stringify(data), {
|
||||
@@ -428,7 +430,11 @@ describe('Chat Edit API Route', () => {
|
||||
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockDelete).toHaveBeenCalled()
|
||||
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
|
||||
chatId: 'chat-123',
|
||||
userId: 'user-id',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
const data = await response.json()
|
||||
expect(data.message).toBe('Chat deployment deleted successfully')
|
||||
})
|
||||
@@ -451,7 +457,11 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
|
||||
expect(mockDelete).toHaveBeenCalled()
|
||||
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
|
||||
chatId: 'chat-123',
|
||||
userId: 'admin-user-id',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ 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'
|
||||
@@ -270,33 +271,25 @@ export async function DELETE(
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const {
|
||||
hasAccess,
|
||||
chat: chatRecord,
|
||||
workspaceId: chatWorkspaceId,
|
||||
} = await checkChatAccess(chatId, session.user.id)
|
||||
const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess(
|
||||
chatId,
|
||||
session.user.id
|
||||
)
|
||||
|
||||
if (!hasAccess) {
|
||||
return createErrorResponse('Chat not found or access denied', 404)
|
||||
}
|
||||
|
||||
await db.delete(chat).where(eq(chat.id, chatId))
|
||||
|
||||
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,
|
||||
const result = await performChatUndeploy({
|
||||
chatId,
|
||||
userId: session.user.id,
|
||||
workspaceId: chatWorkspaceId,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return createErrorResponse(result.error || 'Failed to delete chat', 500)
|
||||
}
|
||||
|
||||
return createSuccessResponse({
|
||||
message: 'Chat deployment deleted successfully',
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { auditMock, createEnvMock } from '@sim/testing'
|
||||
import { createEnvMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -12,66 +12,51 @@ const {
|
||||
mockFrom,
|
||||
mockWhere,
|
||||
mockLimit,
|
||||
mockInsert,
|
||||
mockValues,
|
||||
mockReturning,
|
||||
mockCreateSuccessResponse,
|
||||
mockCreateErrorResponse,
|
||||
mockEncryptSecret,
|
||||
mockCheckWorkflowAccessForChatCreation,
|
||||
mockDeployWorkflow,
|
||||
mockPerformChatDeploy,
|
||||
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(),
|
||||
mockDeployWorkflow: vi.fn(),
|
||||
mockPerformChatDeploy: 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' },
|
||||
chat: { userId: 'userId', identifier: 'identifier', archivedAt: 'archivedAt' },
|
||||
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/persistence/utils', () => ({
|
||||
deployWorkflow: mockDeployWorkflow,
|
||||
vi.mock('@/lib/workflows/orchestration', () => ({
|
||||
performChatDeploy: mockPerformChatDeploy,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
@@ -94,10 +79,6 @@ 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), {
|
||||
@@ -113,12 +94,10 @@ describe('Chat API Route', () => {
|
||||
})
|
||||
})
|
||||
|
||||
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' })
|
||||
|
||||
mockDeployWorkflow.mockResolvedValue({
|
||||
mockPerformChatDeploy.mockResolvedValue({
|
||||
success: true,
|
||||
version: 1,
|
||||
deployedAt: new Date(),
|
||||
chatId: 'test-uuid',
|
||||
chatUrl: 'http://localhost:3000/chat/test-chat',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -277,7 +256,6 @@ 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',
|
||||
@@ -287,6 +265,13 @@ 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 () => {
|
||||
@@ -309,7 +294,6 @@ 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',
|
||||
@@ -319,6 +303,12 @@ 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 () => {
|
||||
@@ -383,7 +373,7 @@ describe('Chat API Route', () => {
|
||||
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
|
||||
})
|
||||
|
||||
it('should auto-deploy workflow if not already deployed', async () => {
|
||||
it('should call performChatDeploy for undeployed workflow', async () => {
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { id: 'user-id', email: 'user@example.com' },
|
||||
})
|
||||
@@ -403,7 +393,6 @@ 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',
|
||||
@@ -412,10 +401,12 @@ describe('Chat API Route', () => {
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockDeployWorkflow).toHaveBeenCalledWith({
|
||||
workflowId: 'workflow-123',
|
||||
deployedBy: 'user-id',
|
||||
})
|
||||
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowId: 'workflow-123',
|
||||
userId: 'user-id',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,14 +3,9 @@ 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 { 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 { performChatDeploy } from '@/lib/workflows/orchestration'
|
||||
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -109,7 +104,6 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Check identifier availability and workflow access in parallel
|
||||
const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
@@ -127,121 +121,27 @@ export async function POST(request: NextRequest) {
|
||||
return createErrorResponse('Workflow not found or access denied', 404)
|
||||
}
|
||||
|
||||
// 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,
|
||||
const result = await performChatDeploy({
|
||||
workflowId,
|
||||
userId: session.user.id,
|
||||
identifier,
|
||||
title,
|
||||
description: description || null,
|
||||
customizations: mergedCustomizations,
|
||||
isActive: true,
|
||||
description,
|
||||
customizations,
|
||||
authType,
|
||||
password: encryptedPassword,
|
||||
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
|
||||
password,
|
||||
allowedEmails,
|
||||
outputConfigs,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
workspaceId: workflowRecord.workspaceId,
|
||||
})
|
||||
|
||||
// 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}`
|
||||
}
|
||||
if (!result.success) {
|
||||
return createErrorResponse(result.error || 'Failed to deploy chat', 500)
|
||||
}
|
||||
|
||||
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,
|
||||
chatUrl,
|
||||
id: result.chatId,
|
||||
chatUrl: result.chatUrl,
|
||||
message: 'Chat deployment created successfully',
|
||||
})
|
||||
} catch (validationError) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
|
||||
import { getAccessibleCopilotChat, resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
|
||||
import {
|
||||
@@ -14,7 +15,6 @@ 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,36 +183,31 @@ export async function POST(req: NextRequest) {
|
||||
const wf = await getWorkflowById(workflowId)
|
||||
resolvedWorkspaceId = wf?.workspaceId ?? undefined
|
||||
} catch {
|
||||
logger.warn(
|
||||
appendCopilotLogContext('Failed to resolve workspaceId from workflow', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageId,
|
||||
})
|
||||
)
|
||||
logger
|
||||
.withMetadata({ requestId: tracker.requestId, messageId: userMessageId })
|
||||
.warn('Failed to resolve workspaceId from workflow')
|
||||
}
|
||||
|
||||
const userMessageIdToUse = userMessageId || crypto.randomUUID()
|
||||
const reqLogger = logger.withMetadata({
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
})
|
||||
try {
|
||||
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,
|
||||
}
|
||||
)
|
||||
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,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
let currentChat: any = null
|
||||
@@ -250,40 +245,22 @@ export async function POST(req: NextRequest) {
|
||||
actualChatId
|
||||
)
|
||||
agentContexts = processed
|
||||
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),
|
||||
}
|
||||
)
|
||||
reqLogger.info('Contexts processed for request', {
|
||||
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
|
||||
) {
|
||||
logger.warn(
|
||||
appendCopilotLogContext(
|
||||
'Contexts provided but none processed. Check executionId for logs contexts.',
|
||||
{
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}
|
||||
)
|
||||
reqLogger.warn(
|
||||
'Contexts provided but none processed. Check executionId for logs contexts.'
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Failed to process contexts', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
e
|
||||
)
|
||||
reqLogger.error('Failed to process contexts', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,13 +289,7 @@ export async function POST(req: NextRequest) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
agentContexts.push(result.value)
|
||||
} else if (result.status === 'rejected') {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Failed to resolve resource attachment', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
result.reason
|
||||
)
|
||||
reqLogger.error('Failed to resolve resource attachment', result.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,26 +328,20 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
|
||||
try {
|
||||
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,
|
||||
}
|
||||
)
|
||||
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,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
if (stream && actualChatId) {
|
||||
@@ -520,16 +485,10 @@ export async function POST(req: NextRequest) {
|
||||
.where(eq(copilotChats.id, actualChatId))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Failed to persist chat messages', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
{
|
||||
chatId: actualChatId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
)
|
||||
reqLogger.error('Failed to persist chat messages', {
|
||||
chatId: actualChatId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -539,10 +498,26 @@ export async function POST(req: NextRequest) {
|
||||
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
|
||||
}
|
||||
|
||||
const nsExecutionId = crypto.randomUUID()
|
||||
const nsRunId = crypto.randomUUID()
|
||||
|
||||
if (actualChatId) {
|
||||
await createRunSegment({
|
||||
id: nsRunId,
|
||||
executionId: nsExecutionId,
|
||||
chatId: actualChatId,
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
streamId: userMessageIdToUse,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const nonStreamingResult = await orchestrateCopilotStream(requestPayload, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
chatId: actualChatId,
|
||||
executionId: nsExecutionId,
|
||||
runId: nsRunId,
|
||||
goRoute: '/api/copilot',
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
@@ -555,19 +530,13 @@ export async function POST(req: NextRequest) {
|
||||
provider: typeof requestPayload?.provider === 'string' ? requestPayload.provider : undefined,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
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,
|
||||
})
|
||||
|
||||
// Save messages if we have a chat
|
||||
if (currentChat && responseData.content) {
|
||||
@@ -600,12 +569,7 @@ 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) {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Starting title generation for non-streaming response', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
})
|
||||
)
|
||||
reqLogger.info('Starting title generation for non-streaming response')
|
||||
requestChatTitle({ message, model: selectedModel, provider, messageId: userMessageIdToUse })
|
||||
.then(async (title) => {
|
||||
if (title) {
|
||||
@@ -616,22 +580,11 @@ export async function POST(req: NextRequest) {
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
logger.error(
|
||||
appendCopilotLogContext(`Generated and saved title: ${title}`, {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
})
|
||||
)
|
||||
reqLogger.info(`Generated and saved title: ${title}`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Title generation failed', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
error
|
||||
)
|
||||
reqLogger.error('Title generation failed', error)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -645,17 +598,11 @@ export async function POST(req: NextRequest) {
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
}
|
||||
|
||||
logger.error(
|
||||
appendCopilotLogContext('Returning non-streaming response', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdToUse,
|
||||
}),
|
||||
{
|
||||
duration: tracker.getDuration(),
|
||||
chatId: actualChatId,
|
||||
responseLength: responseData.content?.length || 0,
|
||||
}
|
||||
)
|
||||
reqLogger.info('Returning non-streaming response', {
|
||||
duration: tracker.getDuration(),
|
||||
chatId: actualChatId,
|
||||
responseLength: responseData.content?.length || 0,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -679,33 +626,25 @@ export async function POST(req: NextRequest) {
|
||||
const duration = tracker.getDuration()
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Validation error', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: pendingChatStreamID ?? undefined,
|
||||
}),
|
||||
{
|
||||
logger
|
||||
.withMetadata({ requestId: tracker.requestId, messageId: pendingChatStreamID ?? undefined })
|
||||
.error('Validation error', {
|
||||
duration,
|
||||
errors: error.errors,
|
||||
}
|
||||
)
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(
|
||||
appendCopilotLogContext('Error handling copilot chat', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: pendingChatStreamID ?? undefined,
|
||||
}),
|
||||
{
|
||||
logger
|
||||
.withMetadata({ requestId: tracker.requestId, messageId: pendingChatStreamID ?? undefined })
|
||||
.error('Error handling copilot chat', {
|
||||
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' },
|
||||
@@ -750,16 +689,13 @@ export async function GET(req: NextRequest) {
|
||||
status: meta?.status || 'unknown',
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
appendCopilotLogContext('Failed to read stream snapshot for chat', {
|
||||
messageId: chat.conversationId || undefined,
|
||||
}),
|
||||
{
|
||||
logger
|
||||
.withMetadata({ messageId: chat.conversationId || undefined })
|
||||
.warn('Failed to read stream snapshot for chat', {
|
||||
chatId,
|
||||
conversationId: chat.conversationId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,11 +714,9 @@ export async function GET(req: NextRequest) {
|
||||
...(streamSnapshot ? { streamSnapshot } : {}),
|
||||
}
|
||||
|
||||
logger.error(
|
||||
appendCopilotLogContext(`Retrieved chat ${chatId}`, {
|
||||
messageId: chat.conversationId || undefined,
|
||||
})
|
||||
)
|
||||
logger
|
||||
.withMetadata({ messageId: chat.conversationId || undefined })
|
||||
.info(`Retrieved chat ${chatId}`)
|
||||
return NextResponse.json({ success: true, chat: transformedChat })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { appendCopilotLogContext } from '@/lib/copilot/logging'
|
||||
import {
|
||||
getStreamMeta,
|
||||
readStreamEvents,
|
||||
@@ -36,24 +35,21 @@ export async function GET(request: NextRequest) {
|
||||
const toParam = url.searchParams.get('to')
|
||||
const toEventId = toParam ? Number(toParam) : undefined
|
||||
|
||||
logger.error(
|
||||
appendCopilotLogContext('[Resume] Received resume request', {
|
||||
messageId: streamId || undefined,
|
||||
}),
|
||||
{
|
||||
streamId: streamId || undefined,
|
||||
fromEventId,
|
||||
toEventId,
|
||||
batchMode,
|
||||
}
|
||||
)
|
||||
const reqLogger = logger.withMetadata({ messageId: streamId || undefined })
|
||||
|
||||
reqLogger.info('[Resume] Received resume request', {
|
||||
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
|
||||
logger.error(appendCopilotLogContext('[Resume] Stream lookup', { messageId: streamId }), {
|
||||
reqLogger.info('[Resume] Stream lookup', {
|
||||
streamId,
|
||||
fromEventId,
|
||||
toEventId,
|
||||
@@ -72,7 +68,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
|
||||
logger.error(appendCopilotLogContext('[Resume] Batch response', { messageId: streamId }), {
|
||||
reqLogger.info('[Resume] Batch response', {
|
||||
streamId,
|
||||
fromEventId,
|
||||
toEventId,
|
||||
@@ -124,14 +120,11 @@ export async function GET(request: NextRequest) {
|
||||
const flushEvents = async () => {
|
||||
const events = await readStreamEvents(streamId, lastEventId)
|
||||
if (events.length > 0) {
|
||||
logger.error(
|
||||
appendCopilotLogContext('[Resume] Flushing events', { messageId: streamId }),
|
||||
{
|
||||
streamId,
|
||||
fromEventId: lastEventId,
|
||||
eventCount: events.length,
|
||||
}
|
||||
)
|
||||
reqLogger.info('[Resume] Flushing events', {
|
||||
streamId,
|
||||
fromEventId: lastEventId,
|
||||
eventCount: events.length,
|
||||
})
|
||||
}
|
||||
for (const entry of events) {
|
||||
lastEventId = entry.eventId
|
||||
@@ -178,7 +171,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controllerClosed && !request.signal.aborted) {
|
||||
logger.warn(appendCopilotLogContext('Stream replay failed', { messageId: streamId }), {
|
||||
reqLogger.warn('Stream replay failed', {
|
||||
streamId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
mockReturning,
|
||||
mockSelect,
|
||||
mockFrom,
|
||||
mockWhere,
|
||||
mockAuthenticate,
|
||||
mockCreateUnauthorizedResponse,
|
||||
mockCreateBadRequestResponse,
|
||||
@@ -23,6 +24,7 @@ const {
|
||||
mockReturning: vi.fn(),
|
||||
mockSelect: vi.fn(),
|
||||
mockFrom: vi.fn(),
|
||||
mockWhere: vi.fn(),
|
||||
mockAuthenticate: vi.fn(),
|
||||
mockCreateUnauthorizedResponse: vi.fn(),
|
||||
mockCreateBadRequestResponse: vi.fn(),
|
||||
@@ -81,7 +83,8 @@ describe('Copilot Feedback API Route', () => {
|
||||
mockValues.mockReturnValue({ returning: mockReturning })
|
||||
mockReturning.mockResolvedValue([])
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockResolvedValue([])
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockResolvedValue([])
|
||||
|
||||
mockCreateRequestTracker.mockReturnValue({
|
||||
requestId: 'test-request-id',
|
||||
@@ -386,7 +389,7 @@ edges:
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFrom.mockResolvedValueOnce([])
|
||||
mockWhere.mockResolvedValueOnce([])
|
||||
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
@@ -397,7 +400,7 @@ edges:
|
||||
expect(responseData.feedback).toEqual([])
|
||||
})
|
||||
|
||||
it('should return all feedback records', async () => {
|
||||
it('should only return feedback records for the authenticated user', async () => {
|
||||
mockAuthenticate.mockResolvedValueOnce({
|
||||
userId: 'user-123',
|
||||
isAuthenticated: true,
|
||||
@@ -415,19 +418,8 @@ 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'),
|
||||
},
|
||||
]
|
||||
mockFrom.mockResolvedValueOnce(mockFeedback)
|
||||
mockWhere.mockResolvedValueOnce(mockFeedback)
|
||||
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
@@ -435,9 +427,14 @@ edges:
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.success).toBe(true)
|
||||
expect(responseData.feedback).toHaveLength(2)
|
||||
expect(responseData.feedback).toHaveLength(1)
|
||||
expect(responseData.feedback[0].feedbackId).toBe('feedback-1')
|
||||
expect(responseData.feedback[1].feedbackId).toBe('feedback-2')
|
||||
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')
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
@@ -446,7 +443,7 @@ edges:
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFrom.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
mockWhere.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
@@ -462,7 +459,7 @@ edges:
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
mockFrom.mockResolvedValueOnce([])
|
||||
mockWhere.mockResolvedValueOnce([])
|
||||
|
||||
const request = new Request('http://localhost:3000/api/copilot/feedback')
|
||||
const response = await GET(request as any)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -109,7 +110,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
/**
|
||||
* GET /api/copilot/feedback
|
||||
* Get all feedback records (for analytics)
|
||||
* Get feedback records for the authenticated user
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const tracker = createRequestTracker()
|
||||
@@ -123,7 +124,7 @@ export async function GET(req: NextRequest) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
// Get all feedback records
|
||||
// Get feedback records for the authenticated user only
|
||||
const feedbackRecords = await db
|
||||
.select({
|
||||
feedbackId: copilotFeedback.feedbackId,
|
||||
@@ -137,6 +138,7 @@ 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`)
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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')
|
||||
@@ -16,6 +20,11 @@ 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')
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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')
|
||||
@@ -22,6 +26,11 @@ 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) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getCredentialActorContext } from '@/lib/credentials/access'
|
||||
import {
|
||||
syncPersonalEnvCredentialsForUser,
|
||||
@@ -17,12 +18,19 @@ const updateCredentialSchema = z
|
||||
.object({
|
||||
displayName: z.string().trim().min(1).max(255).optional(),
|
||||
description: z.string().trim().max(500).nullish(),
|
||||
serviceAccountJson: z.string().min(1).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => data.displayName !== undefined || data.description !== undefined, {
|
||||
message: 'At least one field must be provided',
|
||||
path: ['displayName'],
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.displayName !== undefined ||
|
||||
data.description !== undefined ||
|
||||
data.serviceAccountJson !== undefined,
|
||||
{
|
||||
message: 'At least one field must be provided',
|
||||
path: ['displayName'],
|
||||
}
|
||||
)
|
||||
|
||||
async function getCredentialResponse(credentialId: string, userId: string) {
|
||||
const [row] = await db
|
||||
@@ -106,12 +114,37 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
updates.description = parseResult.data.description ?? null
|
||||
}
|
||||
|
||||
if (parseResult.data.displayName !== undefined && access.credential.type === 'oauth') {
|
||||
if (
|
||||
parseResult.data.displayName !== undefined &&
|
||||
(access.credential.type === 'oauth' || access.credential.type === 'service_account')
|
||||
) {
|
||||
updates.displayName = parseResult.data.displayName
|
||||
}
|
||||
|
||||
if (
|
||||
parseResult.data.serviceAccountJson !== undefined &&
|
||||
access.credential.type === 'service_account'
|
||||
) {
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(parseResult.data.serviceAccountJson)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 })
|
||||
}
|
||||
if (
|
||||
parsed.type !== 'service_account' ||
|
||||
typeof parsed.client_email !== 'string' ||
|
||||
typeof parsed.private_key !== 'string' ||
|
||||
typeof parsed.project_id !== 'string'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Invalid service account JSON key' }, { status: 400 })
|
||||
}
|
||||
const { encrypted } = await encryptSecret(parseResult.data.serviceAccountJson)
|
||||
updates.encryptedServiceAccountKey = encrypted
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
if (access.credential.type === 'oauth') {
|
||||
if (access.credential.type === 'oauth' || access.credential.type === 'service_account') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'No updatable fields provided.',
|
||||
@@ -134,6 +167,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
const row = await getCredentialResponse(id, session.user.id)
|
||||
return NextResponse.json({ credential: row }, { status: 200 })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('unique')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A service account credential with this name already exists in the workspace' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
logger.error('Failed to update credential', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
|
||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
|
||||
@@ -14,7 +15,7 @@ import { isValidEnvVarName } from '@/executor/constants'
|
||||
|
||||
const logger = createLogger('CredentialsAPI')
|
||||
|
||||
const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal'])
|
||||
const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal', 'service_account'])
|
||||
|
||||
function normalizeEnvKeyInput(raw: string): string {
|
||||
const trimmed = raw.trim()
|
||||
@@ -29,6 +30,56 @@ const listCredentialsSchema = z.object({
|
||||
credentialId: z.string().optional(),
|
||||
})
|
||||
|
||||
const serviceAccountJsonSchema = z
|
||||
.string()
|
||||
.min(1, 'Service account JSON key is required')
|
||||
.transform((val, ctx) => {
|
||||
try {
|
||||
const parsed = JSON.parse(val)
|
||||
if (parsed.type !== 'service_account') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'JSON key must have type "service_account"',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
if (!parsed.client_email || typeof parsed.client_email !== 'string') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'JSON key must contain a valid client_email',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
if (!parsed.private_key || typeof parsed.private_key !== 'string') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'JSON key must contain a valid private_key',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
if (!parsed.project_id || typeof parsed.project_id !== 'string') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'JSON key must contain a valid project_id',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
return parsed as {
|
||||
type: 'service_account'
|
||||
client_email: string
|
||||
private_key: string
|
||||
project_id: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid JSON format',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
})
|
||||
|
||||
const createCredentialSchema = z
|
||||
.object({
|
||||
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
|
||||
@@ -39,6 +90,7 @@ const createCredentialSchema = z
|
||||
accountId: z.string().trim().min(1).optional(),
|
||||
envKey: z.string().trim().min(1).optional(),
|
||||
envOwnerUserId: z.string().trim().min(1).optional(),
|
||||
serviceAccountJson: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.type === 'oauth') {
|
||||
@@ -66,6 +118,17 @@ const createCredentialSchema = z
|
||||
return
|
||||
}
|
||||
|
||||
if (data.type === 'service_account') {
|
||||
if (!data.serviceAccountJson) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'serviceAccountJson is required for service account credentials',
|
||||
path: ['serviceAccountJson'],
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedEnvKey = data.envKey ? normalizeEnvKeyInput(data.envKey) : ''
|
||||
if (!normalizedEnvKey) {
|
||||
ctx.addIssue({
|
||||
@@ -87,14 +150,16 @@ const createCredentialSchema = z
|
||||
|
||||
interface ExistingCredentialSourceParams {
|
||||
workspaceId: string
|
||||
type: 'oauth' | 'env_workspace' | 'env_personal'
|
||||
type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account'
|
||||
accountId?: string | null
|
||||
envKey?: string | null
|
||||
envOwnerUserId?: string | null
|
||||
displayName?: string | null
|
||||
providerId?: string | null
|
||||
}
|
||||
|
||||
async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
|
||||
const { workspaceId, type, accountId, envKey, envOwnerUserId } = params
|
||||
const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params
|
||||
|
||||
if (type === 'oauth' && accountId) {
|
||||
const [row] = await db
|
||||
@@ -142,6 +207,22 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
if (type === 'service_account' && displayName && providerId) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
eq(credential.type, 'service_account'),
|
||||
eq(credential.providerId, providerId),
|
||||
eq(credential.displayName, displayName)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -288,6 +369,7 @@ export async function POST(request: NextRequest) {
|
||||
accountId,
|
||||
envKey,
|
||||
envOwnerUserId,
|
||||
serviceAccountJson,
|
||||
} = parseResult.data
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
|
||||
@@ -301,6 +383,7 @@ export async function POST(request: NextRequest) {
|
||||
let resolvedAccountId: string | null = accountId ?? null
|
||||
const resolvedEnvKey: string | null = envKey ? normalizeEnvKeyInput(envKey) : null
|
||||
let resolvedEnvOwnerUserId: string | null = null
|
||||
let resolvedEncryptedServiceAccountKey: string | null = null
|
||||
|
||||
if (type === 'oauth') {
|
||||
const [accountRow] = await db
|
||||
@@ -335,6 +418,33 @@ export async function POST(request: NextRequest) {
|
||||
resolvedDisplayName =
|
||||
getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId
|
||||
}
|
||||
} else if (type === 'service_account') {
|
||||
if (!serviceAccountJson) {
|
||||
return NextResponse.json(
|
||||
{ error: 'serviceAccountJson is required for service account credentials' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson)
|
||||
if (!jsonParseResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: jsonParseResult.error.errors[0]?.message || 'Invalid service account JSON' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = jsonParseResult.data
|
||||
resolvedProviderId = 'google-service-account'
|
||||
resolvedAccountId = null
|
||||
resolvedEnvOwnerUserId = null
|
||||
|
||||
if (!resolvedDisplayName) {
|
||||
resolvedDisplayName = parsed.client_email
|
||||
}
|
||||
|
||||
const { encrypted } = await encryptSecret(serviceAccountJson)
|
||||
resolvedEncryptedServiceAccountKey = encrypted
|
||||
} else if (type === 'env_personal') {
|
||||
resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id
|
||||
if (resolvedEnvOwnerUserId !== session.user.id) {
|
||||
@@ -363,6 +473,8 @@ export async function POST(request: NextRequest) {
|
||||
accountId: resolvedAccountId,
|
||||
envKey: resolvedEnvKey,
|
||||
envOwnerUserId: resolvedEnvOwnerUserId,
|
||||
displayName: resolvedDisplayName,
|
||||
providerId: resolvedProviderId,
|
||||
})
|
||||
|
||||
if (existingCredential) {
|
||||
@@ -441,12 +553,13 @@ export async function POST(request: NextRequest) {
|
||||
accountId: resolvedAccountId,
|
||||
envKey: resolvedEnvKey,
|
||||
envOwnerUserId: resolvedEnvOwnerUserId,
|
||||
encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey,
|
||||
createdBy: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (type === 'env_workspace' && workspaceRow?.ownerId) {
|
||||
if ((type === 'env_workspace' || type === 'service_account') && workspaceRow?.ownerId) {
|
||||
const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId)
|
||||
if (workspaceUserIds.length > 0) {
|
||||
for (const memberUserId of workspaceUserIds) {
|
||||
|
||||
@@ -100,7 +100,7 @@ const emailTemplates = {
|
||||
trigger: 'api',
|
||||
duration: '2.3s',
|
||||
cost: '$0.0042',
|
||||
logUrl: 'https://sim.ai/workspace/ws_123/logs?search=exec_abc123',
|
||||
logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=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?search=exec_abc123',
|
||||
logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=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?search=exec_abc123',
|
||||
logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=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?search=exec_abc123',
|
||||
logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=exec_abc123',
|
||||
finalOutput: { processed: 150, skipped: 3, status: 'completed' },
|
||||
rateLimits: {
|
||||
sync: { requestsPerMinute: 60, remaining: 45 },
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
import { auditMock, createMockRequest, type MockUser } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetSession, mockGetUserEntityPermissions, mockLogger, mockDbRef } = vi.hoisted(() => {
|
||||
const {
|
||||
mockGetSession,
|
||||
mockGetUserEntityPermissions,
|
||||
mockLogger,
|
||||
mockDbRef,
|
||||
mockPerformDeleteFolder,
|
||||
mockCheckForCircularReference,
|
||||
} = vi.hoisted(() => {
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
@@ -21,6 +28,8 @@ const { mockGetSession, mockGetUserEntityPermissions, mockLogger, mockDbRef } =
|
||||
mockGetUserEntityPermissions: vi.fn(),
|
||||
mockLogger: logger,
|
||||
mockDbRef: { current: null as any },
|
||||
mockPerformDeleteFolder: vi.fn(),
|
||||
mockCheckForCircularReference: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,6 +48,12 @@ 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'
|
||||
|
||||
@@ -144,6 +159,11 @@ 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]', () => {
|
||||
@@ -369,13 +389,17 @@ describe('Individual Folder API Route', () => {
|
||||
it('should prevent circular references when updating parent', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
const circularCheckResults = [{ parentId: 'folder-2' }, { parentId: 'folder-3' }]
|
||||
|
||||
mockDbRef.current = createFolderDbMock({
|
||||
folderLookupResult: { id: 'folder-3', parentId: null, name: 'Folder 3' },
|
||||
circularCheckResults,
|
||||
folderLookupResult: {
|
||||
id: 'folder-3',
|
||||
parentId: null,
|
||||
name: 'Folder 3',
|
||||
workspaceId: 'workspace-123',
|
||||
},
|
||||
})
|
||||
|
||||
mockCheckForCircularReference.mockResolvedValue(true)
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
name: 'Updated Folder 3',
|
||||
parentId: 'folder-1',
|
||||
@@ -388,6 +412,7 @@ 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -409,6 +434,12 @@ 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 () => {
|
||||
@@ -472,6 +503,7 @@ 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 () => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowFolder } from '@sim/db/schema'
|
||||
import { workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { eq } 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 { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle'
|
||||
import { performDeleteFolder } from '@/lib/workflows/orchestration'
|
||||
import { checkForCircularReference } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('FoldersIDAPI')
|
||||
@@ -130,7 +130,6 @@ 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',
|
||||
@@ -144,170 +143,25 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
const result = await performDeleteFolder({
|
||||
folderId: id,
|
||||
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,
|
||||
userId: session.user.id,
|
||||
folderName: existingFolder.name,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
const status =
|
||||
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
|
||||
return NextResponse.json({ error: result.error }, { status })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedItems: deletionStats,
|
||||
deletedItems: result.deletedItems,
|
||||
})
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -26,6 +26,14 @@ 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'
|
||||
|
||||
|
||||
160
apps/sim/app/api/jobs/[jobId]/route.test.ts
Normal file
160
apps/sim/app/api/jobs/[jobId]/route.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockCheckHybridAuth,
|
||||
mockGetDispatchJobRecord,
|
||||
mockGetJobQueue,
|
||||
mockVerifyWorkflowAccess,
|
||||
mockGetWorkflowById,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCheckHybridAuth: vi.fn(),
|
||||
mockGetDispatchJobRecord: vi.fn(),
|
||||
mockGetJobQueue: vi.fn(),
|
||||
mockVerifyWorkflowAccess: vi.fn(),
|
||||
mockGetWorkflowById: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: mockCheckHybridAuth,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/async-jobs', () => ({
|
||||
JOB_STATUS: {
|
||||
PENDING: 'pending',
|
||||
PROCESSING: 'processing',
|
||||
COMPLETED: 'completed',
|
||||
FAILED: 'failed',
|
||||
},
|
||||
getJobQueue: mockGetJobQueue,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/workspace-dispatch/store', () => ({
|
||||
getDispatchJobRecord: mockGetDispatchJobRecord,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('request-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/socket/middleware/permissions', () => ({
|
||||
verifyWorkflowAccess: mockVerifyWorkflowAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
getWorkflowById: mockGetWorkflowById,
|
||||
}))
|
||||
|
||||
import { GET } from './route'
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
return {
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
} as NextRequest
|
||||
}
|
||||
|
||||
describe('GET /api/jobs/[jobId]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockCheckHybridAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
apiKeyType: undefined,
|
||||
workspaceId: undefined,
|
||||
})
|
||||
|
||||
mockVerifyWorkflowAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockGetWorkflowById.mockResolvedValue({
|
||||
id: 'workflow-1',
|
||||
workspaceId: 'workspace-1',
|
||||
})
|
||||
|
||||
mockGetJobQueue.mockResolvedValue({
|
||||
getJob: vi.fn().mockResolvedValue(null),
|
||||
})
|
||||
})
|
||||
|
||||
it('returns dispatcher-aware waiting status with metadata', async () => {
|
||||
mockGetDispatchJobRecord.mockResolvedValue({
|
||||
id: 'dispatch-1',
|
||||
workspaceId: 'workspace-1',
|
||||
lane: 'runtime',
|
||||
queueName: 'workflow-execution',
|
||||
bullmqJobName: 'workflow-execution',
|
||||
bullmqPayload: {},
|
||||
metadata: {
|
||||
workflowId: 'workflow-1',
|
||||
},
|
||||
priority: 10,
|
||||
status: 'waiting',
|
||||
createdAt: 1000,
|
||||
admittedAt: 2000,
|
||||
})
|
||||
|
||||
const response = await GET(createMockRequest(), {
|
||||
params: Promise.resolve({ jobId: 'dispatch-1' }),
|
||||
})
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.status).toBe('waiting')
|
||||
expect(body.metadata.queueName).toBe('workflow-execution')
|
||||
expect(body.metadata.lane).toBe('runtime')
|
||||
expect(body.metadata.workspaceId).toBe('workspace-1')
|
||||
})
|
||||
|
||||
it('returns completed output from dispatch state', async () => {
|
||||
mockGetDispatchJobRecord.mockResolvedValue({
|
||||
id: 'dispatch-2',
|
||||
workspaceId: 'workspace-1',
|
||||
lane: 'interactive',
|
||||
queueName: 'workflow-execution',
|
||||
bullmqJobName: 'direct-workflow-execution',
|
||||
bullmqPayload: {},
|
||||
metadata: {
|
||||
workflowId: 'workflow-1',
|
||||
},
|
||||
priority: 1,
|
||||
status: 'completed',
|
||||
createdAt: 1000,
|
||||
startedAt: 2000,
|
||||
completedAt: 7000,
|
||||
output: { success: true },
|
||||
})
|
||||
|
||||
const response = await GET(createMockRequest(), {
|
||||
params: Promise.resolve({ jobId: 'dispatch-2' }),
|
||||
})
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.status).toBe('completed')
|
||||
expect(body.output).toEqual({ success: true })
|
||||
expect(body.metadata.duration).toBe(5000)
|
||||
})
|
||||
|
||||
it('returns 404 when neither dispatch nor BullMQ job exists', async () => {
|
||||
mockGetDispatchJobRecord.mockResolvedValue(null)
|
||||
|
||||
const response = await GET(createMockRequest(), {
|
||||
params: Promise.resolve({ jobId: 'missing-job' }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getJobQueue, JOB_STATUS } from '@/lib/core/async-jobs'
|
||||
import { getJobQueue } from '@/lib/core/async-jobs'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { presentDispatchOrJobStatus } from '@/lib/core/workspace-dispatch/status'
|
||||
import { getDispatchJobRecord } from '@/lib/core/workspace-dispatch/store'
|
||||
import { createErrorResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('TaskStatusAPI')
|
||||
@@ -23,68 +25,54 @@ export async function GET(
|
||||
|
||||
const authenticatedUserId = authResult.userId
|
||||
|
||||
const dispatchJob = await getDispatchJobRecord(taskId)
|
||||
const jobQueue = await getJobQueue()
|
||||
const job = await jobQueue.getJob(taskId)
|
||||
const job = dispatchJob ? null : await jobQueue.getJob(taskId)
|
||||
|
||||
if (!job) {
|
||||
if (!job && !dispatchJob) {
|
||||
return createErrorResponse('Task not found', 404)
|
||||
}
|
||||
|
||||
if (job.metadata?.workflowId) {
|
||||
const metadataToCheck = dispatchJob?.metadata ?? job?.metadata
|
||||
|
||||
if (metadataToCheck?.workflowId) {
|
||||
const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions')
|
||||
const accessCheck = await verifyWorkflowAccess(
|
||||
authenticatedUserId,
|
||||
job.metadata.workflowId as string
|
||||
metadataToCheck.workflowId as string
|
||||
)
|
||||
if (!accessCheck.hasAccess) {
|
||||
logger.warn(`[${requestId}] Access denied to workflow ${job.metadata.workflowId}`)
|
||||
logger.warn(`[${requestId}] Access denied to workflow ${metadataToCheck.workflowId}`)
|
||||
return createErrorResponse('Access denied', 403)
|
||||
}
|
||||
|
||||
if (authResult.apiKeyType === 'workspace' && authResult.workspaceId) {
|
||||
const { getWorkflowById } = await import('@/lib/workflows/utils')
|
||||
const workflow = await getWorkflowById(job.metadata.workflowId as string)
|
||||
const workflow = await getWorkflowById(metadataToCheck.workflowId as string)
|
||||
if (!workflow?.workspaceId || workflow.workspaceId !== authResult.workspaceId) {
|
||||
return createErrorResponse('API key is not authorized for this workspace', 403)
|
||||
}
|
||||
}
|
||||
} else if (job.metadata?.userId && job.metadata.userId !== authenticatedUserId) {
|
||||
logger.warn(`[${requestId}] Access denied to user ${job.metadata.userId}`)
|
||||
} else if (metadataToCheck?.userId && metadataToCheck.userId !== authenticatedUserId) {
|
||||
logger.warn(`[${requestId}] Access denied to user ${metadataToCheck.userId}`)
|
||||
return createErrorResponse('Access denied', 403)
|
||||
} else if (!job.metadata?.userId && !job.metadata?.workflowId) {
|
||||
} else if (!metadataToCheck?.userId && !metadataToCheck?.workflowId) {
|
||||
logger.warn(`[${requestId}] Access denied to job ${taskId}`)
|
||||
return createErrorResponse('Access denied', 403)
|
||||
}
|
||||
|
||||
const mappedStatus = job.status === JOB_STATUS.PENDING ? 'queued' : job.status
|
||||
|
||||
const presented = presentDispatchOrJobStatus(dispatchJob, job)
|
||||
const response: any = {
|
||||
success: true,
|
||||
taskId,
|
||||
status: mappedStatus,
|
||||
metadata: {
|
||||
startedAt: job.startedAt,
|
||||
},
|
||||
status: presented.status,
|
||||
metadata: presented.metadata,
|
||||
}
|
||||
|
||||
if (job.status === JOB_STATUS.COMPLETED) {
|
||||
response.output = job.output
|
||||
response.metadata.completedAt = job.completedAt
|
||||
if (job.startedAt && job.completedAt) {
|
||||
response.metadata.duration = job.completedAt.getTime() - job.startedAt.getTime()
|
||||
}
|
||||
}
|
||||
|
||||
if (job.status === JOB_STATUS.FAILED) {
|
||||
response.error = job.error
|
||||
response.metadata.completedAt = job.completedAt
|
||||
if (job.startedAt && job.completedAt) {
|
||||
response.metadata.duration = job.completedAt.getTime() - job.startedAt.getTime()
|
||||
}
|
||||
}
|
||||
|
||||
if (job.status === JOB_STATUS.PROCESSING || job.status === JOB_STATUS.PENDING) {
|
||||
response.estimatedDuration = 300000
|
||||
if (presented.output !== undefined) response.output = presented.output
|
||||
if (presented.error !== undefined) response.error = presented.error
|
||||
if (presented.estimatedDuration !== undefined) {
|
||||
response.estimatedDuration = presented.estimatedDuration
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
|
||||
@@ -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' }])
|
||||
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }])
|
||||
mockDbChain.returning.mockResolvedValueOnce([{ id: 'conn-456' }])
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
|
||||
@@ -292,7 +292,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const connectorDocuments = await db.transaction(async (tx) => {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const deleteDocuments = searchParams.get('deleteDocuments') === 'true'
|
||||
|
||||
const { deletedDocs, docCount } = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`)
|
||||
|
||||
const docs = await tx
|
||||
@@ -306,10 +309,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
)
|
||||
)
|
||||
|
||||
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))
|
||||
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 deletedConnectors = await tx
|
||||
@@ -328,16 +333,23 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
throw new Error('Connector not found')
|
||||
}
|
||||
|
||||
return docs
|
||||
return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length }
|
||||
})
|
||||
|
||||
await deleteDocumentStorageFiles(connectorDocuments, requestId)
|
||||
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 cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
|
||||
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Hard-deleted connector ${connectorId} and its documents`)
|
||||
logger.info(
|
||||
`[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}`
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: writeCheck.knowledgeBase.workspaceId,
|
||||
@@ -349,7 +361,11 @@ 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: connectorDocuments.length },
|
||||
metadata: {
|
||||
knowledgeBaseId,
|
||||
documentsDeleted: deleteDocuments ? docCount : 0,
|
||||
documentsKept: deleteDocuments ? 0 : docCount,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ 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({
|
||||
@@ -88,6 +90,8 @@ 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)
|
||||
|
||||
@@ -18,6 +18,7 @@ import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
|
||||
import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import { orchestrateSubagentStream } from '@/lib/copilot/orchestrator/subagent'
|
||||
@@ -727,10 +728,25 @@ async function handleBuildToolCall(
|
||||
chatId,
|
||||
}
|
||||
|
||||
const executionId = crypto.randomUUID()
|
||||
const runId = crypto.randomUUID()
|
||||
const messageId = requestPayload.messageId as string
|
||||
|
||||
await createRunSegment({
|
||||
id: runId,
|
||||
executionId,
|
||||
chatId,
|
||||
userId,
|
||||
workflowId: resolved.workflowId,
|
||||
streamId: messageId,
|
||||
}).catch(() => {})
|
||||
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId,
|
||||
workflowId: resolved.workflowId,
|
||||
chatId,
|
||||
executionId,
|
||||
runId,
|
||||
goRoute: '/api/mcp',
|
||||
autoExecuteTools: true,
|
||||
timeout: ORCHESTRATION_TIMEOUT_MS,
|
||||
|
||||
@@ -12,7 +12,6 @@ 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'
|
||||
@@ -112,27 +111,22 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const userMessageId = providedMessageId || crypto.randomUUID()
|
||||
userMessageIdForLogs = userMessageId
|
||||
const reqLogger = logger.withMetadata({
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageId,
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
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,
|
||||
})
|
||||
|
||||
try {
|
||||
await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId)
|
||||
@@ -174,13 +168,7 @@ export async function POST(req: NextRequest) {
|
||||
actualChatId
|
||||
)
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Failed to process contexts', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageId,
|
||||
}),
|
||||
e
|
||||
)
|
||||
reqLogger.error('Failed to process contexts', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,13 +193,7 @@ export async function POST(req: NextRequest) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
agentContexts.push(result.value)
|
||||
} else if (result.status === 'rejected') {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Failed to resolve resource attachment', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageId,
|
||||
}),
|
||||
result.reason
|
||||
)
|
||||
reqLogger.error('Failed to resolve resource attachment', result.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,16 +381,10 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
appendCopilotLogContext('Failed to persist chat messages', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageId,
|
||||
}),
|
||||
{
|
||||
chatId: actualChatId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
)
|
||||
reqLogger.error('Failed to persist chat messages', {
|
||||
chatId: actualChatId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -423,15 +399,11 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(
|
||||
appendCopilotLogContext('Error handling mothership chat', {
|
||||
requestId: tracker.requestId,
|
||||
messageId: userMessageIdForLogs,
|
||||
}),
|
||||
{
|
||||
logger
|
||||
.withMetadata({ requestId: tracker.requestId, messageId: userMessageIdForLogs })
|
||||
.error('Error handling mothership chat', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
|
||||
@@ -5,6 +5,7 @@ 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')
|
||||
@@ -58,6 +59,8 @@ 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(),
|
||||
|
||||
@@ -5,7 +5,6 @@ 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,
|
||||
@@ -63,16 +62,13 @@ export async function GET(
|
||||
status: meta?.status || 'unknown',
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
appendCopilotLogContext('Failed to read stream snapshot for mothership chat', {
|
||||
messageId: chat.conversationId || undefined,
|
||||
}),
|
||||
{
|
||||
logger
|
||||
.withMetadata({ messageId: chat.conversationId || undefined })
|
||||
.warn('Failed to read stream snapshot for mothership chat', {
|
||||
chatId,
|
||||
conversationId: chat.conversationId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
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,6 +52,7 @@ 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),
|
||||
@@ -71,17 +72,31 @@ export async function POST(req: NextRequest) {
|
||||
...(userPermission ? { userPermission } : {}),
|
||||
}
|
||||
|
||||
const executionId = crypto.randomUUID()
|
||||
const runId = crypto.randomUUID()
|
||||
|
||||
await createRunSegment({
|
||||
id: runId,
|
||||
executionId,
|
||||
chatId: effectiveChatId,
|
||||
userId,
|
||||
workspaceId,
|
||||
streamId: messageId,
|
||||
}).catch(() => {})
|
||||
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId,
|
||||
workspaceId,
|
||||
chatId: effectiveChatId,
|
||||
executionId,
|
||||
runId,
|
||||
goRoute: '/api/mothership/execute',
|
||||
autoExecuteTools: true,
|
||||
interactive: false,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
logger.error(appendCopilotLogContext('Mothership execute failed', { messageId }), {
|
||||
reqLogger.error('Mothership execute failed', {
|
||||
error: result.error,
|
||||
errors: result.errors,
|
||||
})
|
||||
@@ -120,7 +135,7 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(appendCopilotLogContext('Mothership execute error', { messageId }), {
|
||||
logger.withMetadata({ messageId }).error('Mothership execute error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
|
||||
|
||||
@@ -61,6 +61,21 @@ 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)
|
||||
|
||||
@@ -6,7 +6,11 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import {
|
||||
getServiceAccountToken,
|
||||
refreshTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
import type { StreamingExecution } from '@/executor/types'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
|
||||
@@ -365,6 +369,14 @@ async function resolveVertexCredential(requestId: string, credentialId: string):
|
||||
throw new Error(`Vertex AI credential not found: ${credentialId}`)
|
||||
}
|
||||
|
||||
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
|
||||
const accessToken = await getServiceAccountToken(resolved.credentialId, [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
])
|
||||
logger.info(`[${requestId}] Successfully resolved Vertex AI service account credential`)
|
||||
return accessToken
|
||||
}
|
||||
|
||||
const credential = await db.query.account.findFirst({
|
||||
where: eq(account.id, resolved.accountId),
|
||||
})
|
||||
|
||||
@@ -9,10 +9,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const {
|
||||
mockVerifyCronAuth,
|
||||
mockExecuteScheduleJob,
|
||||
mockExecuteJobInline,
|
||||
mockFeatureFlags,
|
||||
mockDbReturning,
|
||||
mockDbUpdate,
|
||||
mockEnqueue,
|
||||
mockEnqueueWorkspaceDispatch,
|
||||
mockStartJob,
|
||||
mockCompleteJob,
|
||||
mockMarkJobFailed,
|
||||
@@ -22,6 +24,7 @@ const {
|
||||
const mockDbSet = vi.fn().mockReturnValue({ where: mockDbWhere })
|
||||
const mockDbUpdate = vi.fn().mockReturnValue({ set: mockDbSet })
|
||||
const mockEnqueue = vi.fn().mockResolvedValue('job-id-1')
|
||||
const mockEnqueueWorkspaceDispatch = vi.fn().mockResolvedValue('job-id-1')
|
||||
const mockStartJob = vi.fn().mockResolvedValue(undefined)
|
||||
const mockCompleteJob = vi.fn().mockResolvedValue(undefined)
|
||||
const mockMarkJobFailed = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -29,6 +32,7 @@ const {
|
||||
return {
|
||||
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
|
||||
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
|
||||
mockExecuteJobInline: vi.fn().mockResolvedValue(undefined),
|
||||
mockFeatureFlags: {
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
@@ -38,6 +42,7 @@ const {
|
||||
mockDbReturning,
|
||||
mockDbUpdate,
|
||||
mockEnqueue,
|
||||
mockEnqueueWorkspaceDispatch,
|
||||
mockStartJob,
|
||||
mockCompleteJob,
|
||||
mockMarkJobFailed,
|
||||
@@ -50,6 +55,8 @@ vi.mock('@/lib/auth/internal', () => ({
|
||||
|
||||
vi.mock('@/background/schedule-execution', () => ({
|
||||
executeScheduleJob: mockExecuteScheduleJob,
|
||||
executeJobInline: mockExecuteJobInline,
|
||||
releaseScheduleLock: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => mockFeatureFlags)
|
||||
@@ -68,6 +75,22 @@ vi.mock('@/lib/core/async-jobs', () => ({
|
||||
shouldExecuteInline: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/bullmq', () => ({
|
||||
isBullMQEnabled: vi.fn().mockReturnValue(true),
|
||||
createBullMQJobData: vi.fn((payload: unknown) => ({ payload })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/workspace-dispatch', () => ({
|
||||
enqueueWorkspaceDispatch: mockEnqueueWorkspaceDispatch,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
getWorkflowById: vi.fn().mockResolvedValue({
|
||||
id: 'workflow-1',
|
||||
workspaceId: 'workspace-1',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
@@ -142,6 +165,18 @@ const MULTIPLE_SCHEDULES = [
|
||||
},
|
||||
]
|
||||
|
||||
const SINGLE_JOB = [
|
||||
{
|
||||
id: 'job-1',
|
||||
cronExpression: '0 * * * *',
|
||||
failedCount: 0,
|
||||
lastQueuedAt: undefined,
|
||||
sourceUserId: 'user-1',
|
||||
sourceWorkspaceId: 'workspace-1',
|
||||
sourceType: 'job',
|
||||
},
|
||||
]
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
const mockHeaders = new Map([
|
||||
['authorization', 'Bearer test-cron-secret'],
|
||||
@@ -211,30 +246,44 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
expect(data).toHaveProperty('executedCount', 2)
|
||||
})
|
||||
|
||||
it('should queue mothership jobs to BullMQ when available', async () => {
|
||||
mockDbReturning.mockReturnValueOnce([]).mockReturnValueOnce(SINGLE_JOB)
|
||||
|
||||
const response = await GET(createMockRequest())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: 'workspace-1',
|
||||
lane: 'runtime',
|
||||
queueName: 'mothership-job-execution',
|
||||
bullmqJobName: 'mothership-job-execution',
|
||||
bullmqPayload: {
|
||||
payload: {
|
||||
scheduleId: 'job-1',
|
||||
cronExpression: '0 * * * *',
|
||||
failedCount: 0,
|
||||
now: expect.any(String),
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
expect(mockExecuteJobInline).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should enqueue preassigned correlation metadata for schedules', async () => {
|
||||
mockDbReturning.mockReturnValue(SINGLE_SCHEDULE)
|
||||
|
||||
const response = await GET(createMockRequest())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockEnqueue).toHaveBeenCalledWith(
|
||||
'schedule-execution',
|
||||
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scheduleId: 'schedule-1',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'schedule-execution-1',
|
||||
requestId: 'test-request-id',
|
||||
correlation: {
|
||||
executionId: 'schedule-execution-1',
|
||||
requestId: 'test-request-id',
|
||||
source: 'schedule',
|
||||
workflowId: 'workflow-1',
|
||||
scheduleId: 'schedule-1',
|
||||
triggerType: 'schedule',
|
||||
scheduledFor: '2025-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
id: 'schedule-execution-1',
|
||||
workspaceId: 'workspace-1',
|
||||
lane: 'runtime',
|
||||
queueName: 'schedule-execution',
|
||||
bullmqJobName: 'schedule-execution',
|
||||
metadata: {
|
||||
workflowId: 'workflow-1',
|
||||
correlation: {
|
||||
@@ -247,7 +296,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
scheduledFor: '2025-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,9 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
|
||||
import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch'
|
||||
import {
|
||||
executeJobInline,
|
||||
executeScheduleJob,
|
||||
@@ -73,6 +75,8 @@ export async function GET(request: NextRequest) {
|
||||
cronExpression: workflowSchedule.cronExpression,
|
||||
failedCount: workflowSchedule.failedCount,
|
||||
lastQueuedAt: workflowSchedule.lastQueuedAt,
|
||||
sourceWorkspaceId: workflowSchedule.sourceWorkspaceId,
|
||||
sourceUserId: workflowSchedule.sourceUserId,
|
||||
sourceType: workflowSchedule.sourceType,
|
||||
})
|
||||
|
||||
@@ -111,9 +115,40 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const jobId = await jobQueue.enqueue('schedule-execution', payload, {
|
||||
metadata: { workflowId: schedule.workflowId ?? undefined, correlation },
|
||||
})
|
||||
const { getWorkflowById } = await import('@/lib/workflows/utils')
|
||||
const resolvedWorkflow = schedule.workflowId
|
||||
? await getWorkflowById(schedule.workflowId)
|
||||
: null
|
||||
const resolvedWorkspaceId = resolvedWorkflow?.workspaceId
|
||||
|
||||
let jobId: string
|
||||
if (isBullMQEnabled()) {
|
||||
if (!resolvedWorkspaceId) {
|
||||
throw new Error(
|
||||
`Missing workspace for scheduled workflow ${schedule.workflowId}; refusing to bypass workspace admission`
|
||||
)
|
||||
}
|
||||
|
||||
jobId = await enqueueWorkspaceDispatch({
|
||||
id: executionId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
lane: 'runtime',
|
||||
queueName: 'schedule-execution',
|
||||
bullmqJobName: 'schedule-execution',
|
||||
bullmqPayload: createBullMQJobData(payload, {
|
||||
workflowId: schedule.workflowId ?? undefined,
|
||||
correlation,
|
||||
}),
|
||||
metadata: {
|
||||
workflowId: schedule.workflowId ?? undefined,
|
||||
correlation,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
jobId = await jobQueue.enqueue('schedule-execution', payload, {
|
||||
metadata: { workflowId: schedule.workflowId ?? undefined, correlation },
|
||||
})
|
||||
}
|
||||
logger.info(
|
||||
`[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}`
|
||||
)
|
||||
@@ -165,7 +200,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
})
|
||||
|
||||
// Jobs always execute inline (no TriggerDev)
|
||||
// Mothership jobs use BullMQ when available, otherwise direct inline execution.
|
||||
const jobPromises = dueJobs.map(async (job) => {
|
||||
const queueTime = job.lastQueuedAt ?? queuedAt
|
||||
const payload = {
|
||||
@@ -176,7 +211,24 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
await executeJobInline(payload)
|
||||
if (isBullMQEnabled()) {
|
||||
if (!job.sourceWorkspaceId || !job.sourceUserId) {
|
||||
throw new Error(`Mothership job ${job.id} is missing workspace/user ownership`)
|
||||
}
|
||||
|
||||
await enqueueWorkspaceDispatch({
|
||||
workspaceId: job.sourceWorkspaceId!,
|
||||
lane: 'runtime',
|
||||
queueName: 'mothership-job-execution',
|
||||
bullmqJobName: 'mothership-job-execution',
|
||||
bullmqPayload: createBullMQJobData(payload),
|
||||
metadata: {
|
||||
userId: job.sourceUserId,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await executeJobInline(payload)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Job execution failed for ${job.id}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -96,6 +97,18 @@ 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) {
|
||||
@@ -158,6 +171,15 @@ 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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
@@ -166,6 +167,18 @@ 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) {
|
||||
@@ -265,6 +278,15 @@ 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) {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('GoogleDriveFileAPI')
|
||||
@@ -26,6 +27,7 @@ export async function GET(request: NextRequest) {
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const fileId = searchParams.get('fileId')
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
|
||||
|
||||
if (!credentialId || !fileId) {
|
||||
logger.warn(`[${requestId}] Missing required parameters`)
|
||||
@@ -46,7 +48,9 @@ export async function GET(request: NextRequest) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-drive'),
|
||||
impersonateEmail
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
@@ -157,6 +161,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ file }, { status: 200 })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error fetching file from Google Drive`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('GoogleDriveFilesAPI')
|
||||
@@ -85,6 +86,7 @@ export async function GET(request: NextRequest) {
|
||||
const query = searchParams.get('query') || ''
|
||||
const folderId = searchParams.get('folderId') || searchParams.get('parentId') || ''
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credential ID`)
|
||||
@@ -100,7 +102,9 @@ export async function GET(request: NextRequest) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId!,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-drive'),
|
||||
impersonateEmail
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
@@ -175,6 +179,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ files }, { status: 200 })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error fetching files from Google Drive`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
166
apps/sim/app/api/tools/file/manage/route.ts
Normal file
166
apps/sim/app/api/tools/file/manage/route.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,13 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import {
|
||||
getServiceAccountToken,
|
||||
refreshAccessTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
ServiceAccountTokenError,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -26,6 +32,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const labelId = searchParams.get('labelId')
|
||||
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
|
||||
|
||||
if (!credentialId || !labelId) {
|
||||
logger.warn(`[${requestId}] Missing required parameters`)
|
||||
@@ -58,29 +65,40 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
let accessToken: string | null = null
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`)
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
|
||||
accessToken = await getServiceAccountToken(
|
||||
resolved.credentialId,
|
||||
getScopesForService('gmail'),
|
||||
impersonateEmail
|
||||
)
|
||||
} else {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`)
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const accountRow = credentials[0]
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
|
||||
)
|
||||
|
||||
accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId,
|
||||
getScopesForService('gmail')
|
||||
)
|
||||
}
|
||||
|
||||
const accountRow = credentials[0]
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
|
||||
)
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
@@ -127,6 +145,9 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ label: formattedLabel }, { status: 200 })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error fetching Gmail label:`, error)
|
||||
return NextResponse.json({ error: 'Failed to fetch Gmail label' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import {
|
||||
getServiceAccountToken,
|
||||
refreshAccessTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
ServiceAccountTokenError,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('GmailLabelsAPI')
|
||||
@@ -33,6 +39,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const query = searchParams.get('query')
|
||||
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credentialId parameter`)
|
||||
@@ -62,29 +69,40 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
let accessToken: string | null = null
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`)
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
|
||||
accessToken = await getServiceAccountToken(
|
||||
resolved.credentialId,
|
||||
getScopesForService('gmail'),
|
||||
impersonateEmail
|
||||
)
|
||||
} else {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`)
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const accountRow = credentials[0]
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
|
||||
)
|
||||
|
||||
accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId,
|
||||
getScopesForService('gmail')
|
||||
)
|
||||
}
|
||||
|
||||
const accountRow = credentials[0]
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
|
||||
)
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
@@ -139,6 +157,9 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ labels: filteredLabels }, { status: 200 })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error fetching Gmail labels:`, error)
|
||||
return NextResponse.json({ error: 'Failed to fetch Gmail labels' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('GoogleBigQueryDatasetsAPI')
|
||||
|
||||
@@ -20,7 +21,7 @@ export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, projectId } = body
|
||||
const { credential, workflowId, projectId, impersonateEmail } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
@@ -43,7 +44,9 @@ export async function POST(request: Request) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-bigquery'),
|
||||
impersonateEmail
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
@@ -91,6 +94,9 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ datasets })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error('Error processing BigQuery datasets request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve BigQuery datasets', details: (error as Error).message },
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('GoogleBigQueryTablesAPI')
|
||||
|
||||
@@ -12,7 +13,7 @@ export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, projectId, datasetId } = body
|
||||
const { credential, workflowId, projectId, datasetId, impersonateEmail } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
@@ -40,7 +41,9 @@ export async function POST(request: Request) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-bigquery'),
|
||||
impersonateEmail
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
@@ -85,6 +88,9 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ tables })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error('Error processing BigQuery tables request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve BigQuery tables', details: (error as Error).message },
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('GoogleCalendarAPI')
|
||||
@@ -28,6 +29,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credentialId parameter`)
|
||||
@@ -41,7 +43,9 @@ export async function GET(request: NextRequest) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-calendar'),
|
||||
impersonateEmail
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
@@ -98,6 +102,10 @@ export async function GET(request: NextRequest) {
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error fetching Google calendars`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -40,6 +41,7 @@ export async function GET(request: NextRequest) {
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const spreadsheetId = searchParams.get('spreadsheetId')
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credentialId parameter`)
|
||||
@@ -59,7 +61,9 @@ export async function GET(request: NextRequest) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-sheets'),
|
||||
impersonateEmail
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
@@ -114,6 +118,10 @@ export async function GET(request: NextRequest) {
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error fetching Google Sheets sheets`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('GoogleTasksTaskListsAPI')
|
||||
|
||||
@@ -12,7 +13,7 @@ export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
const { credential, workflowId, impersonateEmail } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
@@ -30,7 +31,9 @@ export async function POST(request: Request) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-tasks'),
|
||||
impersonateEmail
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
@@ -70,6 +73,9 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ taskLists })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error('Error processing Google Tasks task lists request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Google Tasks task lists', details: (error as Error).message },
|
||||
|
||||
@@ -2,6 +2,7 @@ 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')
|
||||
|
||||
@@ -9,7 +10,6 @@ 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, rejectUnauthorized, username, password } = body
|
||||
const { host, port, secure, username, password } = body
|
||||
|
||||
if (!host || !username || !password) {
|
||||
return NextResponse.json(
|
||||
@@ -31,8 +31,14 @@ 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,
|
||||
host: hostValidation.resolvedIP!,
|
||||
servername: host,
|
||||
port: port || 993,
|
||||
secure: secure ?? true,
|
||||
auth: {
|
||||
@@ -40,7 +46,7 @@ export async function POST(request: NextRequest) {
|
||||
pass: password,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: rejectUnauthorized ?? true,
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
logger: false,
|
||||
})
|
||||
@@ -79,21 +85,12 @@ 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'
|
||||
let userMessage = 'Failed to connect to IMAP server. Please check your connection settings.'
|
||||
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 })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -91,16 +92,23 @@ 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 function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
|
||||
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()
|
||||
|
||||
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() !== ''
|
||||
@@ -111,7 +119,7 @@ export function createSftpConnection(config: SftpConnectionConfig): Promise<Clie
|
||||
}
|
||||
|
||||
const connectConfig: ConnectConfig = {
|
||||
host: host.trim(),
|
||||
host: resolvedHost,
|
||||
port,
|
||||
username: config.username,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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'
|
||||
@@ -56,6 +57,15 @@ 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,
|
||||
@@ -64,8 +74,13 @@ 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: validatedData.smtpHost,
|
||||
host: pinnedHost,
|
||||
port: validatedData.smtpPort,
|
||||
secure: validatedData.smtpSecure === 'SSL',
|
||||
auth: {
|
||||
@@ -74,12 +89,8 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
tls:
|
||||
validatedData.smtpSecure === 'None'
|
||||
? {
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
: {
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
? { rejectUnauthorized: false, servername: validatedData.smtpHost }
|
||||
: { rejectUnauthorized: true, servername: validatedData.smtpHost },
|
||||
})
|
||||
|
||||
const contentType = validatedData.contentType || 'text'
|
||||
@@ -189,16 +200,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') {
|
||||
} else if (
|
||||
error.code === 'ECONNECTION' ||
|
||||
error.code === 'ECONNREFUSED' ||
|
||||
error.code === 'ECONNRESET' ||
|
||||
error.code === 'ETIMEDOUT'
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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')
|
||||
|
||||
@@ -108,16 +109,23 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
|
||||
* - keepaliveInterval: 0 (disabled, same as OpenSSH ServerAliveInterval)
|
||||
* - keepaliveCountMax: 3 (same as OpenSSH ServerAliveCountMax)
|
||||
*/
|
||||
export function createSSHConnection(config: SSHConnectionConfig): Promise<Client> {
|
||||
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()
|
||||
|
||||
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() !== ''
|
||||
@@ -128,7 +136,7 @@ export function createSSHConnection(config: SSHConnectionConfig): Promise<Client
|
||||
}
|
||||
|
||||
const connectConfig: ConnectConfig = {
|
||||
host: host.trim(),
|
||||
host: resolvedHost,
|
||||
port,
|
||||
username: config.username,
|
||||
}
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
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 {
|
||||
activateWorkflowVersionById,
|
||||
deployWorkflow,
|
||||
loadWorkflowFromNormalizedTables,
|
||||
undeployWorkflow,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
cleanupDeploymentVersion,
|
||||
createSchedulesForDeploy,
|
||||
validateWorkflowSchedules,
|
||||
} from '@/lib/workflows/schedules'
|
||||
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
@@ -31,12 +13,19 @@ 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()
|
||||
@@ -48,140 +37,28 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalizedData) {
|
||||
return badRequestResponse('Workflow has no saved state')
|
||||
}
|
||||
|
||||
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({
|
||||
const result = await performFullDeploy({
|
||||
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,
|
||||
workflowName: workflowRecord.name,
|
||||
requestId,
|
||||
deploymentVersionId: deployResult.deploymentVersionId,
|
||||
previousVersionId,
|
||||
request,
|
||||
actorId: 'admin-api',
|
||||
})
|
||||
|
||||
if (!triggerSaveResult.success) {
|
||||
await cleanupDeploymentVersion({
|
||||
workflowId,
|
||||
workflow: workflowData,
|
||||
requestId,
|
||||
deploymentVersionId: deployResult.deploymentVersionId,
|
||||
})
|
||||
await rollbackDeployment()
|
||||
return internalErrorResponse(
|
||||
triggerSaveResult.error?.message || 'Failed to sync trigger configuration'
|
||||
)
|
||||
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 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' })
|
||||
logger.info(`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${result.version}`)
|
||||
|
||||
const response: AdminDeployResult = {
|
||||
isDeployed: true,
|
||||
version: deployResult.version!,
|
||||
deployedAt: deployResult.deployedAt!.toISOString(),
|
||||
warnings: triggerSaveResult.warnings,
|
||||
version: result.version!,
|
||||
deployedAt: result.deployedAt!.toISOString(),
|
||||
warnings: result.warnings,
|
||||
}
|
||||
|
||||
return singleResponse(response)
|
||||
@@ -191,7 +68,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()
|
||||
|
||||
@@ -202,19 +79,17 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
const result = await undeployWorkflow({ workflowId })
|
||||
const result = await performFullUndeploy({
|
||||
workflowId,
|
||||
userId: workflowRecord.userId,
|
||||
requestId,
|
||||
actorId: 'admin-api',
|
||||
})
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { templates, workflowBlocks, workflowEdges } from '@sim/db/schema'
|
||||
import { 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 { archiveWorkflow } from '@/lib/workflows/lifecycle'
|
||||
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
|
||||
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,12 +79,18 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
|
||||
return notFoundResponse('Workflow')
|
||||
}
|
||||
|
||||
await db.update(templates).set({ workflowId: null }).where(eq(templates.workflowId, workflowId))
|
||||
|
||||
await archiveWorkflow(workflowId, {
|
||||
const result = await performDeleteWorkflow({
|
||||
workflowId,
|
||||
userId: workflowData.userId,
|
||||
skipLastWorkflowGuard: true,
|
||||
requestId: `admin-workflow-${workflowId}`,
|
||||
actorId: 'admin-api',
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return internalErrorResponse(result.error || 'Failed to delete workflow')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Deleted workflow ${workflowId} (${workflowData.name})`)
|
||||
|
||||
return NextResponse.json({ success: true, workflowId })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user