mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-13 07:55:09 -05:00
Compare commits
69 Commits
feat/atlas
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b45f3962fc | ||
|
|
7fbbc7ba7a | ||
|
|
a337aa7dfe | ||
|
|
022e84c4b1 | ||
|
|
602e371a7a | ||
|
|
9a06cae591 | ||
|
|
dce47a101c | ||
|
|
1130f8ddb2 | ||
|
|
ebc2ffa1c5 | ||
|
|
fc97ce007d | ||
|
|
6c006cdfec | ||
|
|
c380e59cb3 | ||
|
|
2944579d21 | ||
|
|
81dfeb0bb0 | ||
|
|
07d50f8fe1 | ||
|
|
27973953f6 | ||
|
|
50585273ce | ||
|
|
654cb2b407 | ||
|
|
6c66521d64 | ||
|
|
479cd347ad | ||
|
|
a3a99eda19 | ||
|
|
1a66d48add | ||
|
|
46822e91f3 | ||
|
|
2bb68335ee | ||
|
|
8528fbe2d2 | ||
|
|
31fdd2be13 | ||
|
|
028bc652c2 | ||
|
|
c6bf5cd58c | ||
|
|
11dc18a80d | ||
|
|
ab4e9dc72f | ||
|
|
1c58c35bd8 | ||
|
|
d63a5cb504 | ||
|
|
8bd5d41723 | ||
|
|
c12931bc50 | ||
|
|
e9c4251c1c | ||
|
|
cc2be33d6b | ||
|
|
45371e521e | ||
|
|
0ce0f98aa5 | ||
|
|
dff1c9d083 | ||
|
|
b09f683072 | ||
|
|
a8bb0db660 | ||
|
|
af82820a28 | ||
|
|
4372841797 | ||
|
|
5e8c843241 | ||
|
|
7bf3d73ee6 | ||
|
|
7ffc11a738 | ||
|
|
be578e2ed7 | ||
|
|
f415e5edc4 | ||
|
|
13a6e6c3fa | ||
|
|
f5ab7f21ae | ||
|
|
bfb6fffe38 | ||
|
|
4fbec0a43f | ||
|
|
585f5e365b | ||
|
|
3792bdd252 | ||
|
|
eb5d1f3e5b | ||
|
|
54ab82c8dd | ||
|
|
f895bf469b | ||
|
|
dd3209af06 | ||
|
|
b6ba3b50a7 | ||
|
|
b304233062 | ||
|
|
57e4b49bd6 | ||
|
|
e12dd204ed | ||
|
|
3d9d9cbc54 | ||
|
|
0f4ec962ad | ||
|
|
4827866f9a | ||
|
|
3e697d9ed9 | ||
|
|
4431a1a484 | ||
|
|
4d1a9a3f22 | ||
|
|
eb07a080fb |
@@ -1157,6 +1157,21 @@ export function AirweaveIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GoogleBooksIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 478.633 540.068'>
|
||||||
|
<path
|
||||||
|
fill='#1C51A4'
|
||||||
|
d='M449.059,218.231L245.519,99.538l-0.061,193.23c0.031,1.504-0.368,2.977-1.166,4.204c-0.798,1.258-1.565,1.995-2.915,2.547c-1.35,0.552-2.792,0.706-4.204,0.399c-1.412-0.307-2.7-1.043-3.713-2.117l-69.166-70.609l-69.381,70.179c-1.013,0.982-2.301,1.657-3.652,1.903c-1.381,0.246-2.792,0.092-4.081-0.491c-1.289-0.583-1.626-0.522-2.394-1.749c-0.767-1.197-1.197-2.608-1.197-4.081L85.031,6.007l-2.915-1.289C43.973-11.638,0,16.409,0,59.891v420.306c0,46.029,49.312,74.782,88.775,51.767l360.285-210.138C488.491,298.782,488.491,241.246,449.059,218.231z'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='#80D7FB'
|
||||||
|
d='M88.805,8.124c-2.179-1.289-4.419-2.363-6.659-3.345l0.123,288.663c0,1.442,0.43,2.854,1.197,4.081c0.767,1.197,1.872,2.148,3.161,2.731c1.289,0.583,2.7,0.736,4.081,0.491c1.381-0.246,2.639-0.921,3.652-1.903l69.749-69.688l69.811,69.749c1.013,1.074,2.301,1.81,3.713,2.117c1.412,0.307,2.884,0.153,4.204-0.399c1.319-0.552,2.455-1.565,3.253-2.792c0.798-1.258,1.197-2.731,1.166-4.204V99.998L88.805,8.124z'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function GoogleDocsIcon(props: SVGProps<SVGSVGElement>) {
|
export function GoogleDocsIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
GithubIcon,
|
GithubIcon,
|
||||||
GitLabIcon,
|
GitLabIcon,
|
||||||
GmailIcon,
|
GmailIcon,
|
||||||
|
GoogleBooksIcon,
|
||||||
GoogleCalendarIcon,
|
GoogleCalendarIcon,
|
||||||
GoogleDocsIcon,
|
GoogleDocsIcon,
|
||||||
GoogleDriveIcon,
|
GoogleDriveIcon,
|
||||||
@@ -172,6 +173,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
|||||||
github_v2: GithubIcon,
|
github_v2: GithubIcon,
|
||||||
gitlab: GitLabIcon,
|
gitlab: GitLabIcon,
|
||||||
gmail_v2: GmailIcon,
|
gmail_v2: GmailIcon,
|
||||||
|
google_books: GoogleBooksIcon,
|
||||||
google_calendar_v2: GoogleCalendarIcon,
|
google_calendar_v2: GoogleCalendarIcon,
|
||||||
google_docs: GoogleDocsIcon,
|
google_docs: GoogleDocsIcon,
|
||||||
google_drive: GoogleDriveIcon,
|
google_drive: GoogleDriveIcon,
|
||||||
|
|||||||
@@ -88,8 +88,7 @@ Update a Confluence page using the Confluence API.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | Timestamp of update |
|
||||||
| `success` | boolean | Operation success status |
|
|
||||||
| `pageId` | string | Confluence page ID |
|
| `pageId` | string | Confluence page ID |
|
||||||
| `title` | string | Updated page title |
|
| `title` | string | Updated page title |
|
||||||
| `status` | string | Page status |
|
| `status` | string | Page status |
|
||||||
@@ -111,6 +110,7 @@ Update a Confluence page using the Confluence API.
|
|||||||
| ↳ `authorId` | string | Account ID of the version author |
|
| ↳ `authorId` | string | Account ID of the version author |
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||||
| `url` | string | URL to view the page in Confluence |
|
| `url` | string | URL to view the page in Confluence |
|
||||||
|
| `success` | boolean | Update operation success status |
|
||||||
|
|
||||||
### `confluence_create_page`
|
### `confluence_create_page`
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ Create a new page in a Confluence space.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | Timestamp of creation |
|
||||||
| `pageId` | string | Created page ID |
|
| `pageId` | string | Created page ID |
|
||||||
| `title` | string | Page title |
|
| `title` | string | Page title |
|
||||||
| `status` | string | Page status |
|
| `status` | string | Page status |
|
||||||
@@ -172,9 +172,9 @@ Delete a Confluence page. By default moves to trash; use purge=true to permanent
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | Timestamp of deletion |
|
||||||
| `deleted` | boolean | Deletion status |
|
|
||||||
| `pageId` | string | Deleted page ID |
|
| `pageId` | string | Deleted page ID |
|
||||||
|
| `deleted` | boolean | Deletion status |
|
||||||
|
|
||||||
### `confluence_list_pages_in_space`
|
### `confluence_list_pages_in_space`
|
||||||
|
|
||||||
@@ -358,10 +358,10 @@ List all custom properties (metadata) attached to a Confluence page.
|
|||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `pageId` | string | ID of the page |
|
| `pageId` | string | ID of the page |
|
||||||
| `properties` | array | Array of content properties |
|
| `properties` | array | Array of content properties |
|
||||||
| ↳ `id` | string | Unique property identifier |
|
| ↳ `id` | string | Property ID |
|
||||||
| ↳ `key` | string | Property key/name |
|
| ↳ `key` | string | Property key |
|
||||||
| ↳ `value` | json | Property value \(can be any JSON\) |
|
| ↳ `value` | json | Property value \(can be any JSON\) |
|
||||||
| ↳ `version` | object | Property version information |
|
| ↳ `version` | object | Version information |
|
||||||
| ↳ `number` | number | Version number |
|
| ↳ `number` | number | Version number |
|
||||||
| ↳ `message` | string | Version message |
|
| ↳ `message` | string | Version message |
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
@@ -388,50 +388,16 @@ Create a new custom property (metadata) on a Confluence page.
|
|||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `id` | string | Unique property identifier |
|
|
||||||
| `key` | string | Property key/name |
|
|
||||||
| `value` | json | Property value \(can be any JSON\) |
|
|
||||||
| `version` | object | Property version information |
|
|
||||||
| ↳ `number` | number | Version number |
|
|
||||||
| ↳ `message` | string | Version message |
|
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
|
||||||
| ↳ `authorId` | string | Account ID of the version author |
|
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
|
||||||
| `pageId` | string | ID of the page |
|
| `pageId` | string | ID of the page |
|
||||||
| `propertyId` | string | ID of the created property |
|
| `propertyId` | string | ID of the created property |
|
||||||
|
| `key` | string | Property key |
|
||||||
### `confluence_update_page_property`
|
| `value` | json | Property value |
|
||||||
|
| `version` | object | Version information |
|
||||||
Update an existing content property on a Confluence page.
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `pageId` | string | Yes | The ID of the page containing the property |
|
|
||||||
| `propertyId` | string | Yes | The ID of the property to update |
|
|
||||||
| `key` | string | Yes | The key/name of the property |
|
|
||||||
| `value` | json | Yes | The new value for the property \(can be any JSON value\) |
|
|
||||||
| `versionNumber` | number | Yes | The current version number of the property \(for conflict prevention\) |
|
|
||||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
|
||||||
| `id` | string | Unique property identifier |
|
|
||||||
| `key` | string | Property key/name |
|
|
||||||
| `value` | json | Property value \(can be any JSON\) |
|
|
||||||
| `version` | object | Property version information |
|
|
||||||
| ↳ `number` | number | Version number |
|
| ↳ `number` | number | Version number |
|
||||||
| ↳ `message` | string | Version message |
|
| ↳ `message` | string | Version message |
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
| ↳ `authorId` | string | Account ID of the version author |
|
| ↳ `authorId` | string | Account ID of the version author |
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||||
| `pageId` | string | ID of the page |
|
|
||||||
| `propertyId` | string | ID of the updated property |
|
|
||||||
|
|
||||||
### `confluence_delete_page_property`
|
### `confluence_delete_page_property`
|
||||||
|
|
||||||
@@ -472,7 +438,7 @@ Search for content across Confluence pages, blog posts, and other content.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | Timestamp of search |
|
||||||
| `results` | array | Array of search results |
|
| `results` | array | Array of search results |
|
||||||
| ↳ `id` | string | Unique content identifier |
|
| ↳ `id` | string | Unique content identifier |
|
||||||
| ↳ `title` | string | Content title |
|
| ↳ `title` | string | Content title |
|
||||||
@@ -546,29 +512,19 @@ List all blog posts across all accessible Confluence spaces.
|
|||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `blogPosts` | array | Array of blog posts |
|
| `blogPosts` | array | Array of blog posts |
|
||||||
| ↳ `id` | string | Unique blog post identifier |
|
| ↳ `id` | string | Blog post ID |
|
||||||
| ↳ `title` | string | Blog post title |
|
| ↳ `title` | string | Blog post title |
|
||||||
| ↳ `status` | string | Blog post status \(e.g., current, draft\) |
|
| ↳ `status` | string | Blog post status |
|
||||||
| ↳ `spaceId` | string | ID of the space containing the blog post |
|
| ↳ `spaceId` | string | Space ID |
|
||||||
| ↳ `authorId` | string | Account ID of the blog post author |
|
| ↳ `authorId` | string | Author account ID |
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp when the blog post was created |
|
| ↳ `createdAt` | string | Creation timestamp |
|
||||||
| ↳ `version` | object | Blog post version information |
|
| ↳ `version` | object | Version information |
|
||||||
| ↳ `number` | number | Version number |
|
| ↳ `number` | number | Version number |
|
||||||
| ↳ `message` | string | Version message |
|
| ↳ `message` | string | Version message |
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
| ↳ `authorId` | string | Account ID of the version author |
|
| ↳ `authorId` | string | Account ID of the version author |
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||||
| ↳ `body` | object | Blog post body content |
|
| ↳ `webUrl` | string | URL to view the blog post |
|
||||||
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
|
||||||
| ↳ `representation` | string | Content representation type |
|
|
||||||
| ↳ `view` | object | Body in view format \(rendered HTML\) |
|
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
|
||||||
| ↳ `representation` | string | Content representation type |
|
|
||||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
|
||||||
| ↳ `representation` | string | Content representation type |
|
|
||||||
| ↳ `webUrl` | string | URL to view the blog post in Confluence |
|
|
||||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||||
|
|
||||||
### `confluence_get_blogpost`
|
### `confluence_get_blogpost`
|
||||||
@@ -589,19 +545,19 @@ Get a specific Confluence blog post by ID, including its content.
|
|||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `id` | string | Unique blog post identifier |
|
| `id` | string | Blog post ID |
|
||||||
| `title` | string | Blog post title |
|
| `title` | string | Blog post title |
|
||||||
| `status` | string | Blog post status \(e.g., current, draft\) |
|
| `status` | string | Blog post status |
|
||||||
| `spaceId` | string | ID of the space containing the blog post |
|
| `spaceId` | string | Space ID |
|
||||||
| `authorId` | string | Account ID of the blog post author |
|
| `authorId` | string | Author account ID |
|
||||||
| `createdAt` | string | ISO 8601 timestamp when the blog post was created |
|
| `createdAt` | string | Creation timestamp |
|
||||||
| `version` | object | Blog post version information |
|
| `version` | object | Version information |
|
||||||
| ↳ `number` | number | Version number |
|
| ↳ `number` | number | Version number |
|
||||||
| ↳ `message` | string | Version message |
|
| ↳ `message` | string | Version message |
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
| ↳ `authorId` | string | Account ID of the version author |
|
| ↳ `authorId` | string | Account ID of the version author |
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||||
| `body` | object | Blog post body content |
|
| `body` | object | Blog post body content in requested format\(s\) |
|
||||||
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
| ↳ `representation` | string | Content representation type |
|
| ↳ `representation` | string | Content representation type |
|
||||||
@@ -611,7 +567,7 @@ Get a specific Confluence blog post by ID, including its content.
|
|||||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
| ↳ `representation` | string | Content representation type |
|
| ↳ `representation` | string | Content representation type |
|
||||||
| `webUrl` | string | URL to view the blog post in Confluence |
|
| `webUrl` | string | URL to view the blog post |
|
||||||
|
|
||||||
### `confluence_create_blogpost`
|
### `confluence_create_blogpost`
|
||||||
|
|
||||||
@@ -633,18 +589,11 @@ Create a new blog post in a Confluence space.
|
|||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `id` | string | Unique blog post identifier |
|
| `id` | string | Created blog post ID |
|
||||||
| `title` | string | Blog post title |
|
| `title` | string | Blog post title |
|
||||||
| `status` | string | Blog post status \(e.g., current, draft\) |
|
| `status` | string | Blog post status |
|
||||||
| `spaceId` | string | ID of the space containing the blog post |
|
| `spaceId` | string | Space ID |
|
||||||
| `authorId` | string | Account ID of the blog post author |
|
| `authorId` | string | Author account ID |
|
||||||
| `createdAt` | string | ISO 8601 timestamp when the blog post was created |
|
|
||||||
| `version` | object | Blog post version information |
|
|
||||||
| ↳ `number` | number | Version number |
|
|
||||||
| ↳ `message` | string | Version message |
|
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
|
||||||
| ↳ `authorId` | string | Account ID of the version author |
|
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
|
||||||
| `body` | object | Blog post body content |
|
| `body` | object | Blog post body content |
|
||||||
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
@@ -655,71 +604,13 @@ Create a new blog post in a Confluence space.
|
|||||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
| ↳ `representation` | string | Content representation type |
|
| ↳ `representation` | string | Content representation type |
|
||||||
| `webUrl` | string | URL to view the blog post in Confluence |
|
|
||||||
|
|
||||||
### `confluence_update_blogpost`
|
|
||||||
|
|
||||||
Update an existing Confluence blog post title, content, or status.
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `blogPostId` | string | Yes | The ID of the blog post to update |
|
|
||||||
| `title` | string | No | New title for the blog post |
|
|
||||||
| `content` | string | No | New content for the blog post in Confluence storage format |
|
|
||||||
| `status` | string | No | Blog post status: current or draft |
|
|
||||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
|
||||||
| `id` | string | Unique blog post identifier |
|
|
||||||
| `title` | string | Blog post title |
|
|
||||||
| `status` | string | Blog post status \(e.g., current, draft\) |
|
|
||||||
| `spaceId` | string | ID of the space containing the blog post |
|
|
||||||
| `authorId` | string | Account ID of the blog post author |
|
|
||||||
| `createdAt` | string | ISO 8601 timestamp when the blog post was created |
|
|
||||||
| `version` | object | Blog post version information |
|
| `version` | object | Blog post version information |
|
||||||
| ↳ `number` | number | Version number |
|
| ↳ `number` | number | Version number |
|
||||||
| ↳ `message` | string | Version message |
|
| ↳ `message` | string | Version message |
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
| ↳ `authorId` | string | Account ID of the version author |
|
| ↳ `authorId` | string | Account ID of the version author |
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
| ↳ `createdAt` | string | ISO 8601 timestamp of version creation |
|
||||||
| `body` | object | Blog post body content |
|
| `webUrl` | string | URL to view the blog post |
|
||||||
| ↳ `storage` | object | Body in storage format \(Confluence markup\) |
|
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
|
||||||
| ↳ `representation` | string | Content representation type |
|
|
||||||
| ↳ `view` | object | Body in view format \(rendered HTML\) |
|
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
|
||||||
| ↳ `representation` | string | Content representation type |
|
|
||||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
|
||||||
| ↳ `representation` | string | Content representation type |
|
|
||||||
| `webUrl` | string | URL to view the blog post in Confluence |
|
|
||||||
|
|
||||||
### `confluence_delete_blogpost`
|
|
||||||
|
|
||||||
Delete a Confluence blog post.
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `blogPostId` | string | Yes | The ID of the blog post to delete |
|
|
||||||
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
|
||||||
| `deleted` | boolean | Deletion status |
|
|
||||||
| `blogPostId` | string | Deleted blog post ID |
|
|
||||||
|
|
||||||
### `confluence_list_blogposts_in_space`
|
### `confluence_list_blogposts_in_space`
|
||||||
|
|
||||||
@@ -743,13 +634,13 @@ List all blog posts within a specific Confluence space.
|
|||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||||
| `blogPosts` | array | Array of blog posts in the space |
|
| `blogPosts` | array | Array of blog posts in the space |
|
||||||
| ↳ `id` | string | Unique blog post identifier |
|
| ↳ `id` | string | Blog post ID |
|
||||||
| ↳ `title` | string | Blog post title |
|
| ↳ `title` | string | Blog post title |
|
||||||
| ↳ `status` | string | Blog post status \(e.g., current, draft\) |
|
| ↳ `status` | string | Blog post status |
|
||||||
| ↳ `spaceId` | string | ID of the space containing the blog post |
|
| ↳ `spaceId` | string | Space ID |
|
||||||
| ↳ `authorId` | string | Account ID of the blog post author |
|
| ↳ `authorId` | string | Author account ID |
|
||||||
| ↳ `createdAt` | string | ISO 8601 timestamp when the blog post was created |
|
| ↳ `createdAt` | string | Creation timestamp |
|
||||||
| ↳ `version` | object | Blog post version information |
|
| ↳ `version` | object | Version information |
|
||||||
| ↳ `number` | number | Version number |
|
| ↳ `number` | number | Version number |
|
||||||
| ↳ `message` | string | Version message |
|
| ↳ `message` | string | Version message |
|
||||||
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
| ↳ `minorEdit` | boolean | Whether this is a minor edit |
|
||||||
@@ -765,7 +656,7 @@ List all blog posts within a specific Confluence space.
|
|||||||
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
| ↳ `atlas_doc_format` | object | Body in Atlassian Document Format \(ADF\) |
|
||||||
| ↳ `value` | string | The content value in the specified format |
|
| ↳ `value` | string | The content value in the specified format |
|
||||||
| ↳ `representation` | string | Content representation type |
|
| ↳ `representation` | string | Content representation type |
|
||||||
| ↳ `webUrl` | string | URL to view the blog post in Confluence |
|
| ↳ `webUrl` | string | URL to view the blog post |
|
||||||
| `nextCursor` | string | Cursor for fetching the next page of results |
|
| `nextCursor` | string | Cursor for fetching the next page of results |
|
||||||
|
|
||||||
### `confluence_create_comment`
|
### `confluence_create_comment`
|
||||||
@@ -785,7 +676,7 @@ Add a comment to a Confluence page.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | Timestamp of creation |
|
||||||
| `commentId` | string | Created comment ID |
|
| `commentId` | string | Created comment ID |
|
||||||
| `pageId` | string | Page ID |
|
| `pageId` | string | Page ID |
|
||||||
|
|
||||||
@@ -846,9 +737,9 @@ Update an existing comment on a Confluence page.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | Timestamp of update |
|
||||||
| `updated` | boolean | Update status |
|
|
||||||
| `commentId` | string | Updated comment ID |
|
| `commentId` | string | Updated comment ID |
|
||||||
|
| `updated` | boolean | Update status |
|
||||||
|
|
||||||
### `confluence_delete_comment`
|
### `confluence_delete_comment`
|
||||||
|
|
||||||
@@ -866,9 +757,9 @@ Delete a comment from a Confluence page.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | Timestamp of deletion |
|
||||||
| `deleted` | boolean | Deletion status |
|
|
||||||
| `commentId` | string | Deleted comment ID |
|
| `commentId` | string | Deleted comment ID |
|
||||||
|
| `deleted` | boolean | Deletion status |
|
||||||
|
|
||||||
### `confluence_upload_attachment`
|
### `confluence_upload_attachment`
|
||||||
|
|
||||||
@@ -889,7 +780,7 @@ Upload a file as an attachment to a Confluence page.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | Timestamp of upload |
|
||||||
| `attachmentId` | string | Uploaded attachment ID |
|
| `attachmentId` | string | Uploaded attachment ID |
|
||||||
| `title` | string | Attachment file name |
|
| `title` | string | Attachment file name |
|
||||||
| `fileSize` | number | File size in bytes |
|
| `fileSize` | number | File size in bytes |
|
||||||
@@ -951,9 +842,9 @@ Delete an attachment from a Confluence page (moves to trash).
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | Timestamp of deletion |
|
||||||
| `deleted` | boolean | Deletion status |
|
|
||||||
| `attachmentId` | string | Deleted attachment ID |
|
| `attachmentId` | string | Deleted attachment ID |
|
||||||
|
| `deleted` | boolean | Deletion status |
|
||||||
|
|
||||||
### `confluence_list_labels`
|
### `confluence_list_labels`
|
||||||
|
|
||||||
@@ -973,7 +864,7 @@ List all labels on a Confluence page.
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
| `ts` | string | Timestamp of retrieval |
|
||||||
| `labels` | array | Array of labels on the page |
|
| `labels` | array | Array of labels on the page |
|
||||||
| ↳ `id` | string | Unique label identifier |
|
| ↳ `id` | string | Unique label identifier |
|
||||||
| ↳ `name` | string | Label name |
|
| ↳ `name` | string | Label name |
|
||||||
|
|||||||
96
apps/docs/content/docs/en/tools/google_books.mdx
Normal file
96
apps/docs/content/docs/en/tools/google_books.mdx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
title: Google Books
|
||||||
|
description: Search and retrieve book information
|
||||||
|
---
|
||||||
|
|
||||||
|
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||||
|
|
||||||
|
<BlockInfoCard
|
||||||
|
type="google_books"
|
||||||
|
color="#FFFFFF"
|
||||||
|
/>
|
||||||
|
|
||||||
|
## Usage Instructions
|
||||||
|
|
||||||
|
Search for books using the Google Books API. Find volumes by title, author, ISBN, or keywords, and retrieve detailed information about specific books including descriptions, ratings, and publication details.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
### `google_books_volume_search`
|
||||||
|
|
||||||
|
Search for books using the Google Books API
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `apiKey` | string | Yes | Google Books API key |
|
||||||
|
| `query` | string | Yes | Search query. Supports special keywords: intitle:, inauthor:, inpublisher:, subject:, isbn: |
|
||||||
|
| `filter` | string | No | Filter results by availability \(partial, full, free-ebooks, paid-ebooks, ebooks\) |
|
||||||
|
| `printType` | string | No | Restrict to print type \(all, books, magazines\) |
|
||||||
|
| `orderBy` | string | No | Sort order \(relevance, newest\) |
|
||||||
|
| `startIndex` | number | No | Index of the first result to return \(for pagination\) |
|
||||||
|
| `maxResults` | number | No | Maximum number of results to return \(1-40\) |
|
||||||
|
| `langRestrict` | string | No | Restrict results to a specific language \(ISO 639-1 code\) |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `totalItems` | number | Total number of matching results |
|
||||||
|
| `volumes` | array | List of matching volumes |
|
||||||
|
| ↳ `id` | string | Volume ID |
|
||||||
|
| ↳ `title` | string | Book title |
|
||||||
|
| ↳ `subtitle` | string | Book subtitle |
|
||||||
|
| ↳ `authors` | array | List of authors |
|
||||||
|
| ↳ `publisher` | string | Publisher name |
|
||||||
|
| ↳ `publishedDate` | string | Publication date |
|
||||||
|
| ↳ `description` | string | Book description |
|
||||||
|
| ↳ `pageCount` | number | Number of pages |
|
||||||
|
| ↳ `categories` | array | Book categories |
|
||||||
|
| ↳ `averageRating` | number | Average rating \(1-5\) |
|
||||||
|
| ↳ `ratingsCount` | number | Number of ratings |
|
||||||
|
| ↳ `language` | string | Language code |
|
||||||
|
| ↳ `previewLink` | string | Link to preview on Google Books |
|
||||||
|
| ↳ `infoLink` | string | Link to info page |
|
||||||
|
| ↳ `thumbnailUrl` | string | Book cover thumbnail URL |
|
||||||
|
| ↳ `isbn10` | string | ISBN-10 identifier |
|
||||||
|
| ↳ `isbn13` | string | ISBN-13 identifier |
|
||||||
|
|
||||||
|
### `google_books_volume_details`
|
||||||
|
|
||||||
|
Get detailed information about a specific book volume
|
||||||
|
|
||||||
|
#### Input
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ---- | -------- | ----------- |
|
||||||
|
| `apiKey` | string | Yes | Google Books API key |
|
||||||
|
| `volumeId` | string | Yes | The ID of the volume to retrieve |
|
||||||
|
| `projection` | string | No | Projection level \(full, lite\) |
|
||||||
|
|
||||||
|
#### Output
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ---- | ----------- |
|
||||||
|
| `id` | string | Volume ID |
|
||||||
|
| `title` | string | Book title |
|
||||||
|
| `subtitle` | string | Book subtitle |
|
||||||
|
| `authors` | array | List of authors |
|
||||||
|
| `publisher` | string | Publisher name |
|
||||||
|
| `publishedDate` | string | Publication date |
|
||||||
|
| `description` | string | Book description |
|
||||||
|
| `pageCount` | number | Number of pages |
|
||||||
|
| `categories` | array | Book categories |
|
||||||
|
| `averageRating` | number | Average rating \(1-5\) |
|
||||||
|
| `ratingsCount` | number | Number of ratings |
|
||||||
|
| `language` | string | Language code |
|
||||||
|
| `previewLink` | string | Link to preview on Google Books |
|
||||||
|
| `infoLink` | string | Link to info page |
|
||||||
|
| `thumbnailUrl` | string | Book cover thumbnail URL |
|
||||||
|
| `isbn10` | string | ISBN-10 identifier |
|
||||||
|
| `isbn13` | string | ISBN-13 identifier |
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ With Sim’s Jira Service Management integration, you can create, monitor, and u
|
|||||||
|
|
||||||
## Usage Instructions
|
## Usage Instructions
|
||||||
|
|
||||||
Integrate with Jira Service Management for IT service management. Create and manage service requests, handle customers and organizations, track SLAs, and manage queues. Can also trigger workflows based on Jira Service Management webhook events.
|
Integrate with Jira Service Management for IT service management. Create and manage service requests, handle customers and organizations, track SLAs, and manage queues.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -66,31 +66,6 @@ Get all service desks from Jira Service Management
|
|||||||
| `total` | number | Total number of service desks |
|
| `total` | number | Total number of service desks |
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
### `jsm_get_service_desk`
|
|
||||||
|
|
||||||
Get a specific service desk by ID in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `id` | string | Service desk ID |
|
|
||||||
| `projectId` | string | Associated Jira project ID |
|
|
||||||
| `projectName` | string | Associated project name |
|
|
||||||
| `projectKey` | string | Associated project key |
|
|
||||||
| `name` | string | Service desk name |
|
|
||||||
| `description` | string | Service desk description |
|
|
||||||
| `leadDisplayName` | string | Project lead display name |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
|
|
||||||
### `jsm_get_request_types`
|
### `jsm_get_request_types`
|
||||||
|
|
||||||
Get request types for a service desk in Jira Service Management
|
Get request types for a service desk in Jira Service Management
|
||||||
@@ -126,39 +101,6 @@ Get request types for a service desk in Jira Service Management
|
|||||||
| `total` | number | Total number of request types |
|
| `total` | number | Total number of request types |
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
### `jsm_get_request_type_fields`
|
|
||||||
|
|
||||||
Get the fields required to create a request of a specific type in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
|
|
||||||
| `requestTypeId` | string | Yes | Request Type ID \(e.g., "10", "15"\) |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `serviceDeskId` | string | Service desk ID |
|
|
||||||
| `requestTypeId` | string | Request type ID |
|
|
||||||
| `canAddRequestParticipants` | boolean | Whether participants can be added to requests of this type |
|
|
||||||
| `canRaiseOnBehalfOf` | boolean | Whether requests can be raised on behalf of another user |
|
|
||||||
| `requestTypeFields` | array | List of fields for this request type |
|
|
||||||
| ↳ `fieldId` | string | Field identifier \(e.g., summary, description, customfield_10010\) |
|
|
||||||
| ↳ `name` | string | Human-readable field name |
|
|
||||||
| ↳ `description` | string | Help text for the field |
|
|
||||||
| ↳ `required` | boolean | Whether the field is required |
|
|
||||||
| ↳ `visible` | boolean | Whether the field is visible |
|
|
||||||
| ↳ `validValues` | json | Allowed values for select fields |
|
|
||||||
| ↳ `presetValues` | json | Pre-populated values |
|
|
||||||
| ↳ `defaultValues` | json | Default values for the field |
|
|
||||||
| ↳ `jiraSchema` | json | Jira field schema with type, system, custom, customId |
|
|
||||||
|
|
||||||
### `jsm_create_request`
|
### `jsm_create_request`
|
||||||
|
|
||||||
Create a new service request in Jira Service Management
|
Create a new service request in Jira Service Management
|
||||||
@@ -280,59 +222,6 @@ Get multiple service requests from Jira Service Management
|
|||||||
| `total` | number | Total number of requests in current page |
|
| `total` | number | Total number of requests in current page |
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
### `jsm_get_request_status`
|
|
||||||
|
|
||||||
Get status history for a service request in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
|
||||||
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
|
|
||||||
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `issueIdOrKey` | string | Issue ID or key |
|
|
||||||
| `statuses` | array | Status history entries |
|
|
||||||
| ↳ `status` | string | Status name |
|
|
||||||
| ↳ `statusCategory` | string | Status category \(NEW, INDETERMINATE, DONE\) |
|
|
||||||
| ↳ `statusDate` | json | Status change date with iso8601, friendly, epochMillis |
|
|
||||||
| `total` | number | Total number of status entries |
|
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
|
||||||
|
|
||||||
### `jsm_get_request_attachments`
|
|
||||||
|
|
||||||
Get attachments for a service request in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
|
||||||
| `includeAttachments` | boolean | No | Download attachment file contents and include them as files in the output |
|
|
||||||
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
|
|
||||||
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `issueIdOrKey` | string | Issue ID or key |
|
|
||||||
| `attachments` | array | List of attachments |
|
|
||||||
| `total` | number | Total number of attachments |
|
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
|
||||||
| `files` | file[] | Downloaded attachment files \(only when includeAttachments is true\) |
|
|
||||||
|
|
||||||
### `jsm_add_comment`
|
### `jsm_add_comment`
|
||||||
|
|
||||||
Add a comment (public or internal) to a service request in Jira Service Management
|
Add a comment (public or internal) to a service request in Jira Service Management
|
||||||
@@ -452,53 +341,6 @@ Add customers to a service desk in Jira Service Management
|
|||||||
| `serviceDeskId` | string | Service desk ID |
|
| `serviceDeskId` | string | Service desk ID |
|
||||||
| `success` | boolean | Whether customers were added successfully |
|
| `success` | boolean | Whether customers were added successfully |
|
||||||
|
|
||||||
### `jsm_remove_customer`
|
|
||||||
|
|
||||||
Remove customers from a service desk in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
|
|
||||||
| `accountIds` | string | No | Comma-separated Atlassian account IDs to remove |
|
|
||||||
| `emails` | string | No | Comma-separated email addresses to remove |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `serviceDeskId` | string | Service desk ID |
|
|
||||||
| `success` | boolean | Whether customers were removed successfully |
|
|
||||||
|
|
||||||
### `jsm_create_customer`
|
|
||||||
|
|
||||||
Create a new customer in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `email` | string | Yes | Email address for the new customer |
|
|
||||||
| `displayName` | string | Yes | Display name for the new customer |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `accountId` | string | Account ID of the created customer |
|
|
||||||
| `displayName` | string | Display name of the created customer |
|
|
||||||
| `emailAddress` | string | Email address of the created customer |
|
|
||||||
| `active` | boolean | Whether the customer account is active |
|
|
||||||
| `timeZone` | string | Customer timezone |
|
|
||||||
| `success` | boolean | Whether the customer was created successfully |
|
|
||||||
|
|
||||||
### `jsm_get_organizations`
|
### `jsm_get_organizations`
|
||||||
|
|
||||||
Get organizations for a service desk in Jira Service Management
|
Get organizations for a service desk in Jira Service Management
|
||||||
@@ -524,26 +366,6 @@ Get organizations for a service desk in Jira Service Management
|
|||||||
| `total` | number | Total number of organizations |
|
| `total` | number | Total number of organizations |
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
### `jsm_get_organization`
|
|
||||||
|
|
||||||
Get a specific organization by ID in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `organizationId` | string | Yes | Organization ID to retrieve |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `id` | string | Organization ID |
|
|
||||||
| `name` | string | Organization name |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
|
|
||||||
### `jsm_create_organization`
|
### `jsm_create_organization`
|
||||||
|
|
||||||
Create a new organization in Jira Service Management
|
Create a new organization in Jira Service Management
|
||||||
@@ -587,119 +409,6 @@ Add an organization to a service desk in Jira Service Management
|
|||||||
| `organizationId` | string | Organization ID added |
|
| `organizationId` | string | Organization ID added |
|
||||||
| `success` | boolean | Whether the operation succeeded |
|
| `success` | boolean | Whether the operation succeeded |
|
||||||
|
|
||||||
### `jsm_remove_organization`
|
|
||||||
|
|
||||||
Remove an organization from a service desk in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
|
|
||||||
| `organizationId` | string | Yes | Organization ID to remove from the service desk |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `serviceDeskId` | string | Service Desk ID |
|
|
||||||
| `organizationId` | string | Organization ID removed |
|
|
||||||
| `success` | boolean | Whether the operation succeeded |
|
|
||||||
|
|
||||||
### `jsm_delete_organization`
|
|
||||||
|
|
||||||
Delete an organization in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `organizationId` | string | Yes | Organization ID to delete |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `organizationId` | string | ID of the deleted organization |
|
|
||||||
| `success` | boolean | Whether the organization was deleted |
|
|
||||||
|
|
||||||
### `jsm_get_organization_users`
|
|
||||||
|
|
||||||
Get users in an organization in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `organizationId` | string | Yes | Organization ID to get users from |
|
|
||||||
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
|
|
||||||
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `organizationId` | string | Organization ID |
|
|
||||||
| `users` | array | List of users in the organization |
|
|
||||||
| ↳ `accountId` | string | Atlassian account ID |
|
|
||||||
| ↳ `displayName` | string | Display name |
|
|
||||||
| ↳ `emailAddress` | string | Email address |
|
|
||||||
| ↳ `active` | boolean | Whether the account is active |
|
|
||||||
| ↳ `timeZone` | string | User timezone |
|
|
||||||
| `total` | number | Total number of users |
|
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
|
||||||
|
|
||||||
### `jsm_add_organization_users`
|
|
||||||
|
|
||||||
Add users to an organization in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `organizationId` | string | Yes | Organization ID to add users to |
|
|
||||||
| `accountIds` | string | Yes | Comma-separated account IDs to add to the organization |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `organizationId` | string | Organization ID |
|
|
||||||
| `success` | boolean | Whether users were added successfully |
|
|
||||||
|
|
||||||
### `jsm_remove_organization_users`
|
|
||||||
|
|
||||||
Remove users from an organization in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `organizationId` | string | Yes | Organization ID to remove users from |
|
|
||||||
| `accountIds` | string | Yes | Comma-separated account IDs to remove from the organization |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `organizationId` | string | Organization ID |
|
|
||||||
| `success` | boolean | Whether users were removed successfully |
|
|
||||||
|
|
||||||
### `jsm_get_queues`
|
### `jsm_get_queues`
|
||||||
|
|
||||||
Get queues for a service desk in Jira Service Management
|
Get queues for a service desk in Jira Service Management
|
||||||
@@ -729,51 +438,6 @@ Get queues for a service desk in Jira Service Management
|
|||||||
| `total` | number | Total number of queues |
|
| `total` | number | Total number of queues |
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
| `isLastPage` | boolean | Whether this is the last page |
|
||||||
|
|
||||||
### `jsm_get_queue_issues`
|
|
||||||
|
|
||||||
Get issues in a specific queue for a service desk in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
|
|
||||||
| `queueId` | string | Yes | Queue ID to get issues from |
|
|
||||||
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
|
|
||||||
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `serviceDeskId` | string | Service desk ID |
|
|
||||||
| `queueId` | string | Queue ID |
|
|
||||||
| `issues` | array | List of issues in the queue |
|
|
||||||
| ↳ `issueId` | string | Jira issue ID |
|
|
||||||
| ↳ `issueKey` | string | Issue key \(e.g., SD-123\) |
|
|
||||||
| ↳ `requestTypeId` | string | Request type ID |
|
|
||||||
| ↳ `serviceDeskId` | string | Service desk ID |
|
|
||||||
| ↳ `createdDate` | json | Creation date with iso8601, friendly, epochMillis |
|
|
||||||
| ↳ `currentStatus` | object | Current request status |
|
|
||||||
| ↳ `status` | string | Status name |
|
|
||||||
| ↳ `statusCategory` | string | Status category \(NEW, INDETERMINATE, DONE\) |
|
|
||||||
| ↳ `statusDate` | json | Status change date with iso8601, friendly, epochMillis |
|
|
||||||
| ↳ `reporter` | object | Reporter user details |
|
|
||||||
| ↳ `accountId` | string | Atlassian account ID |
|
|
||||||
| ↳ `displayName` | string | User display name |
|
|
||||||
| ↳ `emailAddress` | string | User email address |
|
|
||||||
| ↳ `active` | boolean | Whether the account is active |
|
|
||||||
| ↳ `requestFieldValues` | array | Request field values |
|
|
||||||
| ↳ `fieldId` | string | Field identifier |
|
|
||||||
| ↳ `label` | string | Human-readable field label |
|
|
||||||
| ↳ `value` | json | Field value |
|
|
||||||
| ↳ `renderedValue` | json | HTML-rendered field value |
|
|
||||||
| `total` | number | Total number of issues in the queue |
|
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
|
||||||
|
|
||||||
### `jsm_get_sla`
|
### `jsm_get_sla`
|
||||||
|
|
||||||
Get SLA information for a service request in Jira Service Management
|
Get SLA information for a service request in Jira Service Management
|
||||||
@@ -905,32 +569,6 @@ Add participants to a request in Jira Service Management
|
|||||||
| ↳ `active` | boolean | Whether the account is active |
|
| ↳ `active` | boolean | Whether the account is active |
|
||||||
| `success` | boolean | Whether the operation succeeded |
|
| `success` | boolean | Whether the operation succeeded |
|
||||||
|
|
||||||
### `jsm_remove_participants`
|
|
||||||
|
|
||||||
Remove participants from a request in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
|
||||||
| `accountIds` | string | Yes | Comma-separated account IDs to remove as participants |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `issueIdOrKey` | string | Issue ID or key |
|
|
||||||
| `participants` | array | Remaining participants after removal |
|
|
||||||
| ↳ `accountId` | string | Atlassian account ID |
|
|
||||||
| ↳ `displayName` | string | Display name |
|
|
||||||
| ↳ `emailAddress` | string | Email address |
|
|
||||||
| ↳ `active` | boolean | Whether the account is active |
|
|
||||||
| `success` | boolean | Whether the operation succeeded |
|
|
||||||
|
|
||||||
### `jsm_get_approvals`
|
### `jsm_get_approvals`
|
||||||
|
|
||||||
Get approvals for a request in Jira Service Management
|
Get approvals for a request in Jira Service Management
|
||||||
@@ -1006,9 +644,9 @@ Approve or decline an approval request in Jira Service Management
|
|||||||
| `approval` | json | The approval object |
|
| `approval` | json | The approval object |
|
||||||
| `success` | boolean | Whether the operation succeeded |
|
| `success` | boolean | Whether the operation succeeded |
|
||||||
|
|
||||||
### `jsm_get_feedback`
|
### `jsm_get_request_type_fields`
|
||||||
|
|
||||||
Get CSAT feedback for a service request in Jira Service Management
|
Get the fields required to create a request of a specific type in Jira Service Management
|
||||||
|
|
||||||
#### Input
|
#### Input
|
||||||
|
|
||||||
@@ -1016,152 +654,27 @@ Get CSAT feedback for a service request in Jira Service Management
|
|||||||
| --------- | ---- | -------- | ----------- |
|
| --------- | ---- | -------- | ----------- |
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
||||||
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
| `serviceDeskId` | string | Yes | Service Desk ID \(e.g., "1", "2"\) |
|
||||||
|
| `requestTypeId` | string | Yes | Request Type ID \(e.g., "10", "15"\) |
|
||||||
|
|
||||||
#### Output
|
#### Output
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --------- | ---- | ----------- |
|
| --------- | ---- | ----------- |
|
||||||
| `ts` | string | Timestamp of the operation |
|
| `ts` | string | Timestamp of the operation |
|
||||||
| `issueIdOrKey` | string | Issue ID or key |
|
| `serviceDeskId` | string | Service desk ID |
|
||||||
| `rating` | number | CSAT rating \(1-5\) |
|
| `requestTypeId` | string | Request type ID |
|
||||||
| `comment` | string | Feedback comment |
|
| `canAddRequestParticipants` | boolean | Whether participants can be added to requests of this type |
|
||||||
| `type` | string | Feedback type \(e.g., csat\) |
|
| `canRaiseOnBehalfOf` | boolean | Whether requests can be raised on behalf of another user |
|
||||||
|
| `requestTypeFields` | array | List of fields for this request type |
|
||||||
### `jsm_add_feedback`
|
| ↳ `fieldId` | string | Field identifier \(e.g., summary, description, customfield_10010\) |
|
||||||
|
| ↳ `name` | string | Human-readable field name |
|
||||||
Add CSAT feedback to a service request in Jira Service Management
|
| ↳ `description` | string | Help text for the field |
|
||||||
|
| ↳ `required` | boolean | Whether the field is required |
|
||||||
#### Input
|
| ↳ `visible` | boolean | Whether the field is visible |
|
||||||
|
| ↳ `validValues` | json | Allowed values for select fields |
|
||||||
| Parameter | Type | Required | Description |
|
| ↳ `presetValues` | json | Pre-populated values |
|
||||||
| --------- | ---- | -------- | ----------- |
|
| ↳ `defaultValues` | json | Default values for the field |
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
| ↳ `jiraSchema` | json | Jira field schema with type, system, custom, customId |
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
|
||||||
| `rating` | number | Yes | CSAT rating \(1-5\) |
|
|
||||||
| `comment` | string | No | Optional feedback comment |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `issueIdOrKey` | string | Issue ID or key |
|
|
||||||
| `rating` | number | CSAT rating submitted |
|
|
||||||
| `comment` | string | Feedback comment |
|
|
||||||
| `type` | string | Feedback type |
|
|
||||||
| `success` | boolean | Whether feedback was submitted successfully |
|
|
||||||
|
|
||||||
### `jsm_delete_feedback`
|
|
||||||
|
|
||||||
Delete CSAT feedback from a service request in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `issueIdOrKey` | string | Issue ID or key |
|
|
||||||
| `success` | boolean | Whether feedback was deleted |
|
|
||||||
|
|
||||||
### `jsm_get_notification`
|
|
||||||
|
|
||||||
Get notification subscription status for a request in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `issueIdOrKey` | string | Issue ID or key |
|
|
||||||
| `subscribed` | boolean | Whether currently subscribed to notifications |
|
|
||||||
|
|
||||||
### `jsm_subscribe_notification`
|
|
||||||
|
|
||||||
Subscribe to notifications for a request in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `issueIdOrKey` | string | Issue ID or key |
|
|
||||||
| `success` | boolean | Whether subscription was successful |
|
|
||||||
|
|
||||||
### `jsm_unsubscribe_notification`
|
|
||||||
|
|
||||||
Unsubscribe from notifications for a request in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., SD-123\) |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `issueIdOrKey` | string | Issue ID or key |
|
|
||||||
| `success` | boolean | Whether unsubscription was successful |
|
|
||||||
|
|
||||||
### `jsm_search_knowledge_base`
|
|
||||||
|
|
||||||
Search knowledge base articles in Jira Service Management
|
|
||||||
|
|
||||||
#### Input
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ---- | -------- | ----------- |
|
|
||||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
|
||||||
| `cloudId` | string | No | Jira Cloud ID for the instance |
|
|
||||||
| `serviceDeskId` | string | No | Service Desk ID to search within \(optional, searches globally if omitted\) |
|
|
||||||
| `query` | string | Yes | Search query for knowledge base articles |
|
|
||||||
| `highlight` | boolean | No | Whether to highlight matching text in results |
|
|
||||||
| `start` | number | No | Start index for pagination \(e.g., 0, 50, 100\) |
|
|
||||||
| `limit` | number | No | Maximum results to return \(e.g., 10, 25, 50\) |
|
|
||||||
|
|
||||||
#### Output
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| --------- | ---- | ----------- |
|
|
||||||
| `ts` | string | Timestamp of the operation |
|
|
||||||
| `articles` | array | List of knowledge base articles |
|
|
||||||
| ↳ `title` | string | Article title |
|
|
||||||
| ↳ `excerpt` | string | Article excerpt/summary |
|
|
||||||
| ↳ `sourceType` | string | Source type \(e.g., confluence\) |
|
|
||||||
| ↳ `sourcePageId` | string | Source page ID |
|
|
||||||
| ↳ `sourceSpaceKey` | string | Source space key |
|
|
||||||
| ↳ `contentUrl` | string | URL to rendered content |
|
|
||||||
| `total` | number | Total number of articles found |
|
|
||||||
| `isLastPage` | boolean | Whether this is the last page |
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"github",
|
"github",
|
||||||
"gitlab",
|
"gitlab",
|
||||||
"gmail",
|
"gmail",
|
||||||
|
"google_books",
|
||||||
"google_calendar",
|
"google_calendar",
|
||||||
"google_docs",
|
"google_docs",
|
||||||
"google_drive",
|
"google_drive",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ BETTER_AUTH_URL=http://localhost:3000
|
|||||||
|
|
||||||
# NextJS (Required)
|
# NextJS (Required)
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
# INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL
|
||||||
|
|
||||||
# Security (Required)
|
# Security (Required)
|
||||||
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables
|
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Artifact, Message, PushNotificationConfig, Task, TaskState } from '@a2a-js/sdk'
|
import type { Artifact, Message, PushNotificationConfig, Task, TaskState } from '@a2a-js/sdk'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { generateInternalToken } from '@/lib/auth/internal'
|
import { generateInternalToken } from '@/lib/auth/internal'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||||
|
|
||||||
/** A2A v0.3 JSON-RPC method names */
|
/** A2A v0.3 JSON-RPC method names */
|
||||||
export const A2A_METHODS = {
|
export const A2A_METHODS = {
|
||||||
@@ -118,7 +118,7 @@ export interface ExecuteRequestResult {
|
|||||||
export async function buildExecuteRequest(
|
export async function buildExecuteRequest(
|
||||||
config: ExecuteRequestConfig
|
config: ExecuteRequestConfig
|
||||||
): Promise<ExecuteRequestResult> {
|
): Promise<ExecuteRequestResult> {
|
||||||
const url = `${getBaseUrl()}/api/workflows/${config.workflowId}/execute`
|
const url = `${getInternalApiBaseUrl()}/api/workflows/${config.workflowId}/execute`
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
let useInternalAuth = false
|
let useInternalAuth = false
|
||||||
|
|
||||||
|
|||||||
187
apps/sim/app/api/attribution/route.ts
Normal file
187
apps/sim/app/api/attribution/route.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/attribution
|
||||||
|
*
|
||||||
|
* Automatic UTM-based referral attribution.
|
||||||
|
*
|
||||||
|
* Reads the `sim_utm` cookie (set by proxy on auth pages), matches a campaign
|
||||||
|
* by UTM specificity, and atomically inserts an attribution record + applies
|
||||||
|
* bonus credits.
|
||||||
|
*
|
||||||
|
* Idempotent — the unique constraint on `userId` prevents double-attribution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '@sim/db'
|
||||||
|
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
|
||||||
|
|
||||||
|
const logger = createLogger('AttributionAPI')
|
||||||
|
|
||||||
|
const COOKIE_NAME = 'sim_utm'
|
||||||
|
|
||||||
|
const UtmCookieSchema = z.object({
|
||||||
|
utm_source: z.string().optional(),
|
||||||
|
utm_medium: z.string().optional(),
|
||||||
|
utm_campaign: z.string().optional(),
|
||||||
|
utm_content: z.string().optional(),
|
||||||
|
referrer_url: z.string().optional(),
|
||||||
|
landing_page: z.string().optional(),
|
||||||
|
created_at: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the most specific active campaign matching the given UTM params.
|
||||||
|
* Null fields on a campaign act as wildcards. Ties broken by newest campaign.
|
||||||
|
*/
|
||||||
|
async function findMatchingCampaign(utmData: z.infer<typeof UtmCookieSchema>) {
|
||||||
|
const campaigns = await db
|
||||||
|
.select()
|
||||||
|
.from(referralCampaigns)
|
||||||
|
.where(eq(referralCampaigns.isActive, true))
|
||||||
|
|
||||||
|
let bestMatch: (typeof campaigns)[number] | null = null
|
||||||
|
let bestScore = -1
|
||||||
|
|
||||||
|
for (const campaign of campaigns) {
|
||||||
|
let score = 0
|
||||||
|
let mismatch = false
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{ campaignVal: campaign.utmSource, utmVal: utmData.utm_source },
|
||||||
|
{ campaignVal: campaign.utmMedium, utmVal: utmData.utm_medium },
|
||||||
|
{ campaignVal: campaign.utmCampaign, utmVal: utmData.utm_campaign },
|
||||||
|
{ campaignVal: campaign.utmContent, utmVal: utmData.utm_content },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
for (const { campaignVal, utmVal } of fields) {
|
||||||
|
if (campaignVal === null) continue
|
||||||
|
if (campaignVal === utmVal) {
|
||||||
|
score++
|
||||||
|
} else {
|
||||||
|
mismatch = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mismatch && score > 0) {
|
||||||
|
if (
|
||||||
|
score > bestScore ||
|
||||||
|
(score === bestScore &&
|
||||||
|
bestMatch &&
|
||||||
|
campaign.createdAt.getTime() > bestMatch.createdAt.getTime())
|
||||||
|
) {
|
||||||
|
bestScore = score
|
||||||
|
bestMatch = campaign
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const utmCookie = cookieStore.get(COOKIE_NAME)
|
||||||
|
if (!utmCookie?.value) {
|
||||||
|
return NextResponse.json({ attributed: false, reason: 'no_utm_cookie' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let utmData: z.infer<typeof UtmCookieSchema>
|
||||||
|
try {
|
||||||
|
let decoded: string
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(utmCookie.value)
|
||||||
|
} catch {
|
||||||
|
decoded = utmCookie.value
|
||||||
|
}
|
||||||
|
utmData = UtmCookieSchema.parse(JSON.parse(decoded))
|
||||||
|
} catch {
|
||||||
|
logger.warn('Failed to parse UTM cookie', { userId: session.user.id })
|
||||||
|
cookieStore.delete(COOKIE_NAME)
|
||||||
|
return NextResponse.json({ attributed: false, reason: 'invalid_cookie' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedCampaign = await findMatchingCampaign(utmData)
|
||||||
|
if (!matchedCampaign) {
|
||||||
|
cookieStore.delete(COOKIE_NAME)
|
||||||
|
return NextResponse.json({ attributed: false, reason: 'no_matching_campaign' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const bonusAmount = Number(matchedCampaign.bonusCreditAmount)
|
||||||
|
|
||||||
|
let attributed = false
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [existingStats] = await tx
|
||||||
|
.select({ id: userStats.id })
|
||||||
|
.from(userStats)
|
||||||
|
.where(eq(userStats.userId, session.user.id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existingStats) {
|
||||||
|
await tx.insert(userStats).values({
|
||||||
|
id: nanoid(),
|
||||||
|
userId: session.user.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await tx
|
||||||
|
.insert(referralAttribution)
|
||||||
|
.values({
|
||||||
|
id: nanoid(),
|
||||||
|
userId: session.user.id,
|
||||||
|
campaignId: matchedCampaign.id,
|
||||||
|
utmSource: utmData.utm_source || null,
|
||||||
|
utmMedium: utmData.utm_medium || null,
|
||||||
|
utmCampaign: utmData.utm_campaign || null,
|
||||||
|
utmContent: utmData.utm_content || null,
|
||||||
|
referrerUrl: utmData.referrer_url || null,
|
||||||
|
landingPage: utmData.landing_page || null,
|
||||||
|
bonusCreditAmount: bonusAmount.toString(),
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({ target: referralAttribution.userId })
|
||||||
|
.returning({ id: referralAttribution.id })
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
await applyBonusCredits(session.user.id, bonusAmount, tx)
|
||||||
|
attributed = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (attributed) {
|
||||||
|
logger.info('Referral attribution created and bonus credits applied', {
|
||||||
|
userId: session.user.id,
|
||||||
|
campaignId: matchedCampaign.id,
|
||||||
|
campaignName: matchedCampaign.name,
|
||||||
|
utmSource: utmData.utm_source,
|
||||||
|
utmCampaign: utmData.utm_campaign,
|
||||||
|
utmContent: utmData.utm_content,
|
||||||
|
bonusAmount,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.info('User already attributed, skipping', { userId: session.user.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieStore.delete(COOKIE_NAME)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
attributed,
|
||||||
|
bonusAmount: attributed ? bonusAmount : undefined,
|
||||||
|
reason: attributed ? undefined : 'already_attributed',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Attribution error', { error })
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,20 +4,10 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { loggerMock } from '@sim/testing'
|
import { databaseMock, loggerMock } from '@sim/testing'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
vi.mock('@sim/db', () => ({
|
vi.mock('@sim/db', () => databaseMock)
|
||||||
db: {
|
|
||||||
select: vi.fn().mockReturnThis(),
|
|
||||||
from: vi.fn().mockReturnThis(),
|
|
||||||
where: vi.fn().mockReturnThis(),
|
|
||||||
limit: vi.fn().mockReturnValue([]),
|
|
||||||
update: vi.fn().mockReturnThis(),
|
|
||||||
set: vi.fn().mockReturnThis(),
|
|
||||||
orderBy: vi.fn().mockReturnThis(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/oauth/oauth', () => ({
|
vi.mock('@/lib/oauth/oauth', () => ({
|
||||||
refreshOAuthToken: vi.fn(),
|
refreshOAuthToken: vi.fn(),
|
||||||
@@ -34,13 +24,36 @@ import {
|
|||||||
refreshTokenIfNeeded,
|
refreshTokenIfNeeded,
|
||||||
} from '@/app/api/auth/oauth/utils'
|
} from '@/app/api/auth/oauth/utils'
|
||||||
|
|
||||||
const mockDbTyped = db as any
|
const mockDb = db as any
|
||||||
const mockRefreshOAuthToken = refreshOAuthToken as any
|
const mockRefreshOAuthToken = refreshOAuthToken as any
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a chainable mock for db.select() calls.
|
||||||
|
* Returns a nested chain: select() -> from() -> where() -> limit() / orderBy()
|
||||||
|
*/
|
||||||
|
function mockSelectChain(limitResult: unknown[]) {
|
||||||
|
const mockLimit = vi.fn().mockReturnValue(limitResult)
|
||||||
|
const mockOrderBy = vi.fn().mockReturnValue(limitResult)
|
||||||
|
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit, orderBy: mockOrderBy })
|
||||||
|
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere })
|
||||||
|
mockDb.select.mockReturnValueOnce({ from: mockFrom })
|
||||||
|
return { mockFrom, mockWhere, mockLimit }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a chainable mock for db.update() calls.
|
||||||
|
* Returns a nested chain: update() -> set() -> where()
|
||||||
|
*/
|
||||||
|
function mockUpdateChain() {
|
||||||
|
const mockWhere = vi.fn().mockResolvedValue({})
|
||||||
|
const mockSet = vi.fn().mockReturnValue({ where: mockWhere })
|
||||||
|
mockDb.update.mockReturnValueOnce({ set: mockSet })
|
||||||
|
return { mockSet, mockWhere }
|
||||||
|
}
|
||||||
|
|
||||||
describe('OAuth Utils', () => {
|
describe('OAuth Utils', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockDbTyped.limit.mockReturnValue([])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -50,20 +63,20 @@ describe('OAuth Utils', () => {
|
|||||||
describe('getCredential', () => {
|
describe('getCredential', () => {
|
||||||
it('should return credential when found', async () => {
|
it('should return credential when found', async () => {
|
||||||
const mockCredential = { id: 'credential-id', userId: 'test-user-id' }
|
const mockCredential = { id: 'credential-id', userId: 'test-user-id' }
|
||||||
mockDbTyped.limit.mockReturnValueOnce([mockCredential])
|
const { mockFrom, mockWhere, mockLimit } = mockSelectChain([mockCredential])
|
||||||
|
|
||||||
const credential = await getCredential('request-id', 'credential-id', 'test-user-id')
|
const credential = await getCredential('request-id', 'credential-id', 'test-user-id')
|
||||||
|
|
||||||
expect(mockDbTyped.select).toHaveBeenCalled()
|
expect(mockDb.select).toHaveBeenCalled()
|
||||||
expect(mockDbTyped.from).toHaveBeenCalled()
|
expect(mockFrom).toHaveBeenCalled()
|
||||||
expect(mockDbTyped.where).toHaveBeenCalled()
|
expect(mockWhere).toHaveBeenCalled()
|
||||||
expect(mockDbTyped.limit).toHaveBeenCalledWith(1)
|
expect(mockLimit).toHaveBeenCalledWith(1)
|
||||||
|
|
||||||
expect(credential).toEqual(mockCredential)
|
expect(credential).toEqual(mockCredential)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return undefined when credential is not found', async () => {
|
it('should return undefined when credential is not found', async () => {
|
||||||
mockDbTyped.limit.mockReturnValueOnce([])
|
mockSelectChain([])
|
||||||
|
|
||||||
const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id')
|
const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id')
|
||||||
|
|
||||||
@@ -102,11 +115,12 @@ describe('OAuth Utils', () => {
|
|||||||
refreshToken: 'new-refresh-token',
|
refreshToken: 'new-refresh-token',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mockUpdateChain()
|
||||||
|
|
||||||
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
|
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
|
||||||
|
|
||||||
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
|
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
|
||||||
expect(mockDbTyped.update).toHaveBeenCalled()
|
expect(mockDb.update).toHaveBeenCalled()
|
||||||
expect(mockDbTyped.set).toHaveBeenCalled()
|
|
||||||
expect(result).toEqual({ accessToken: 'new-token', refreshed: true })
|
expect(result).toEqual({ accessToken: 'new-token', refreshed: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -152,7 +166,7 @@ describe('OAuth Utils', () => {
|
|||||||
providerId: 'google',
|
providerId: 'google',
|
||||||
userId: 'test-user-id',
|
userId: 'test-user-id',
|
||||||
}
|
}
|
||||||
mockDbTyped.limit.mockReturnValueOnce([mockCredential])
|
mockSelectChain([mockCredential])
|
||||||
|
|
||||||
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
||||||
|
|
||||||
@@ -169,7 +183,8 @@ describe('OAuth Utils', () => {
|
|||||||
providerId: 'google',
|
providerId: 'google',
|
||||||
userId: 'test-user-id',
|
userId: 'test-user-id',
|
||||||
}
|
}
|
||||||
mockDbTyped.limit.mockReturnValueOnce([mockCredential])
|
mockSelectChain([mockCredential])
|
||||||
|
mockUpdateChain()
|
||||||
|
|
||||||
mockRefreshOAuthToken.mockResolvedValueOnce({
|
mockRefreshOAuthToken.mockResolvedValueOnce({
|
||||||
accessToken: 'new-token',
|
accessToken: 'new-token',
|
||||||
@@ -180,13 +195,12 @@ describe('OAuth Utils', () => {
|
|||||||
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
||||||
|
|
||||||
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
|
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
|
||||||
expect(mockDbTyped.update).toHaveBeenCalled()
|
expect(mockDb.update).toHaveBeenCalled()
|
||||||
expect(mockDbTyped.set).toHaveBeenCalled()
|
|
||||||
expect(token).toBe('new-token')
|
expect(token).toBe('new-token')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return null if credential not found', async () => {
|
it('should return null if credential not found', async () => {
|
||||||
mockDbTyped.limit.mockReturnValueOnce([])
|
mockSelectChain([])
|
||||||
|
|
||||||
const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id')
|
const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id')
|
||||||
|
|
||||||
@@ -202,7 +216,7 @@ describe('OAuth Utils', () => {
|
|||||||
providerId: 'google',
|
providerId: 'google',
|
||||||
userId: 'test-user-id',
|
userId: 'test-user-id',
|
||||||
}
|
}
|
||||||
mockDbTyped.limit.mockReturnValueOnce([mockCredential])
|
mockSelectChain([mockCredential])
|
||||||
|
|
||||||
mockRefreshOAuthToken.mockResolvedValueOnce(null)
|
mockRefreshOAuthToken.mockResolvedValueOnce(null)
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const ChatMessageSchema = z.object({
|
|||||||
chatId: z.string().optional(),
|
chatId: z.string().optional(),
|
||||||
workflowId: z.string().optional(),
|
workflowId: z.string().optional(),
|
||||||
workflowName: z.string().optional(),
|
workflowName: z.string().optional(),
|
||||||
model: z.string().optional().default('claude-opus-4-6'),
|
model: z.string().optional().default('claude-opus-4-5'),
|
||||||
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
|
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
|
||||||
prefetch: z.boolean().optional(),
|
prefetch: z.boolean().optional(),
|
||||||
createNewChat: z.boolean().optional().default(false),
|
createNewChat: z.boolean().optional().default(false),
|
||||||
@@ -238,7 +238,7 @@ export async function POST(req: NextRequest) {
|
|||||||
let currentChat: any = null
|
let currentChat: any = null
|
||||||
let conversationHistory: any[] = []
|
let conversationHistory: any[] = []
|
||||||
let actualChatId = chatId
|
let actualChatId = chatId
|
||||||
const selectedModel = model || 'claude-opus-4-6'
|
const selectedModel = model || 'claude-opus-4-5'
|
||||||
|
|
||||||
if (chatId || createNewChat) {
|
if (chatId || createNewChat) {
|
||||||
const chatResult = await resolveOrCreateChat({
|
const chatResult = await resolveOrCreateChat({
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
|||||||
setupCommonApiMocks()
|
setupCommonApiMocks()
|
||||||
mockCryptoUuid()
|
mockCryptoUuid()
|
||||||
|
|
||||||
// Mock getBaseUrl to return localhost for tests
|
|
||||||
vi.doMock('@/lib/core/utils/urls', () => ({
|
vi.doMock('@/lib/core/utils/urls', () => ({
|
||||||
getBaseUrl: vi.fn(() => 'http://localhost:3000'),
|
getBaseUrl: vi.fn(() => 'http://localhost:3000'),
|
||||||
|
getInternalApiBaseUrl: vi.fn(() => 'http://localhost:3000'),
|
||||||
getBaseDomain: vi.fn(() => 'localhost:3000'),
|
getBaseDomain: vi.fn(() => 'localhost:3000'),
|
||||||
getEmailDomain: vi.fn(() => 'localhost:3000'),
|
getEmailDomain: vi.fn(() => 'localhost:3000'),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
createRequestTracker,
|
createRequestTracker,
|
||||||
createUnauthorizedResponse,
|
createUnauthorizedResponse,
|
||||||
} from '@/lib/copilot/request-helpers'
|
} from '@/lib/copilot/request-helpers'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||||
import { isUuidV4 } from '@/executor/constants'
|
import { isUuidV4 } from '@/executor/constants'
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stateResponse = await fetch(
|
const stateResponse = await fetch(
|
||||||
`${getBaseUrl()}/api/workflows/${checkpoint.workflowId}/state`,
|
`${getInternalApiBaseUrl()}/api/workflows/${checkpoint.workflowId}/state`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -4,16 +4,12 @@
|
|||||||
*
|
*
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import { createEnvMock, createMockLogger } from '@sim/testing'
|
import { createEnvMock, databaseMock, loggerMock } from '@sim/testing'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const loggerMock = vi.hoisted(() => ({
|
|
||||||
createLogger: () => createMockLogger(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('drizzle-orm')
|
vi.mock('drizzle-orm')
|
||||||
vi.mock('@sim/logger', () => loggerMock)
|
vi.mock('@sim/logger', () => loggerMock)
|
||||||
vi.mock('@sim/db')
|
vi.mock('@sim/db', () => databaseMock)
|
||||||
vi.mock('@/lib/knowledge/documents/utils', () => ({
|
vi.mock('@/lib/knowledge/documents/utils', () => ({
|
||||||
retryWithExponentialBackoff: (fn: any) => fn(),
|
retryWithExponentialBackoff: (fn: any) => fn(),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
|
|
||||||
const logger = createLogger('CopilotMcpAPI')
|
const logger = createLogger('CopilotMcpAPI')
|
||||||
const mcpRateLimiter = new RateLimiter()
|
const mcpRateLimiter = new RateLimiter()
|
||||||
const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6'
|
const DEFAULT_COPILOT_MODEL = 'claude-opus-4-5'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ describe('MCP Serve Route', () => {
|
|||||||
}))
|
}))
|
||||||
vi.doMock('@/lib/core/utils/urls', () => ({
|
vi.doMock('@/lib/core/utils/urls', () => ({
|
||||||
getBaseUrl: () => 'http://localhost:3000',
|
getBaseUrl: () => 'http://localhost:3000',
|
||||||
|
getInternalApiBaseUrl: () => 'http://localhost:3000',
|
||||||
}))
|
}))
|
||||||
vi.doMock('@/lib/core/execution-limits', () => ({
|
vi.doMock('@/lib/core/execution-limits', () => ({
|
||||||
getMaxExecutionTimeout: () => 10_000,
|
getMaxExecutionTimeout: () => 10_000,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
|||||||
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
|
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateInternalToken } from '@/lib/auth/internal'
|
import { generateInternalToken } from '@/lib/auth/internal'
|
||||||
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
|
|
||||||
const logger = createLogger('WorkflowMcpServeAPI')
|
const logger = createLogger('WorkflowMcpServeAPI')
|
||||||
@@ -285,7 +285,7 @@ async function handleToolsCall(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const executeUrl = `${getBaseUrl()}/api/workflows/${tool.workflowId}/execute`
|
const executeUrl = `${getInternalApiBaseUrl()}/api/workflows/${tool.workflowId}/execute`
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
|
||||||
if (publicServerOwnerId) {
|
if (publicServerOwnerId) {
|
||||||
|
|||||||
170
apps/sim/app/api/referral-code/redeem/route.ts
Normal file
170
apps/sim/app/api/referral-code/redeem/route.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/referral-code/redeem
|
||||||
|
*
|
||||||
|
* Redeem a referral/promo code to receive bonus credits.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - code: string — The referral code to redeem
|
||||||
|
*
|
||||||
|
* Response: { redeemed: boolean, bonusAmount?: number, error?: string }
|
||||||
|
*
|
||||||
|
* Constraints:
|
||||||
|
* - Enterprise users cannot redeem codes
|
||||||
|
* - One redemption per user, ever (unique constraint on userId)
|
||||||
|
* - One redemption per organization for team users (partial unique on organizationId)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '@sim/db'
|
||||||
|
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { and, eq } from 'drizzle-orm'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||||
|
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
|
||||||
|
|
||||||
|
const logger = createLogger('ReferralCodeRedemption')
|
||||||
|
|
||||||
|
const RedeemCodeSchema = z.object({
|
||||||
|
code: z.string().min(1, 'Code is required'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { code } = RedeemCodeSchema.parse(body)
|
||||||
|
|
||||||
|
const subscription = await getHighestPrioritySubscription(session.user.id)
|
||||||
|
|
||||||
|
if (subscription?.plan === 'enterprise') {
|
||||||
|
return NextResponse.json({
|
||||||
|
redeemed: false,
|
||||||
|
error: 'Enterprise accounts cannot redeem referral codes',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTeam = subscription?.plan === 'team'
|
||||||
|
const orgId = isTeam ? subscription.referenceId : null
|
||||||
|
|
||||||
|
const normalizedCode = code.trim().toUpperCase()
|
||||||
|
|
||||||
|
const [campaign] = await db
|
||||||
|
.select()
|
||||||
|
.from(referralCampaigns)
|
||||||
|
.where(and(eq(referralCampaigns.code, normalizedCode), eq(referralCampaigns.isActive, true)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
logger.info('Invalid code redemption attempt', {
|
||||||
|
userId: session.user.id,
|
||||||
|
code: normalizedCode,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ error: 'Invalid or expired code' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingUserAttribution] = await db
|
||||||
|
.select({ id: referralAttribution.id })
|
||||||
|
.from(referralAttribution)
|
||||||
|
.where(eq(referralAttribution.userId, session.user.id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existingUserAttribution) {
|
||||||
|
return NextResponse.json({
|
||||||
|
redeemed: false,
|
||||||
|
error: 'You have already redeemed a code',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orgId) {
|
||||||
|
const [existingOrgAttribution] = await db
|
||||||
|
.select({ id: referralAttribution.id })
|
||||||
|
.from(referralAttribution)
|
||||||
|
.where(eq(referralAttribution.organizationId, orgId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existingOrgAttribution) {
|
||||||
|
return NextResponse.json({
|
||||||
|
redeemed: false,
|
||||||
|
error: 'A code has already been redeemed for your organization',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bonusAmount = Number(campaign.bonusCreditAmount)
|
||||||
|
|
||||||
|
let redeemed = false
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [existingStats] = await tx
|
||||||
|
.select({ id: userStats.id })
|
||||||
|
.from(userStats)
|
||||||
|
.where(eq(userStats.userId, session.user.id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existingStats) {
|
||||||
|
await tx.insert(userStats).values({
|
||||||
|
id: nanoid(),
|
||||||
|
userId: session.user.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await tx
|
||||||
|
.insert(referralAttribution)
|
||||||
|
.values({
|
||||||
|
id: nanoid(),
|
||||||
|
userId: session.user.id,
|
||||||
|
organizationId: orgId,
|
||||||
|
campaignId: campaign.id,
|
||||||
|
utmSource: null,
|
||||||
|
utmMedium: null,
|
||||||
|
utmCampaign: null,
|
||||||
|
utmContent: null,
|
||||||
|
referrerUrl: null,
|
||||||
|
landingPage: null,
|
||||||
|
bonusCreditAmount: bonusAmount.toString(),
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ id: referralAttribution.id })
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
await applyBonusCredits(session.user.id, bonusAmount, tx)
|
||||||
|
redeemed = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (redeemed) {
|
||||||
|
logger.info('Referral code redeemed', {
|
||||||
|
userId: session.user.id,
|
||||||
|
organizationId: orgId,
|
||||||
|
code: normalizedCode,
|
||||||
|
campaignId: campaign.id,
|
||||||
|
campaignName: campaign.name,
|
||||||
|
bonusAmount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!redeemed) {
|
||||||
|
return NextResponse.json({
|
||||||
|
redeemed: false,
|
||||||
|
error: 'You have already redeemed a code',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
redeemed: true,
|
||||||
|
bonusAmount,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
|
||||||
|
}
|
||||||
|
logger.error('Referral code redemption error', { error })
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,17 +3,14 @@
|
|||||||
*
|
*
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import { loggerMock } from '@sim/testing'
|
import { databaseMock, loggerMock } from '@sim/testing'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission, mockDbSelect, mockDbUpdate } =
|
const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission } = vi.hoisted(() => ({
|
||||||
vi.hoisted(() => ({
|
mockGetSession: vi.fn(),
|
||||||
mockGetSession: vi.fn(),
|
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
|
||||||
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
|
}))
|
||||||
mockDbSelect: vi.fn(),
|
|
||||||
mockDbUpdate: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth', () => ({
|
vi.mock('@/lib/auth', () => ({
|
||||||
getSession: mockGetSession,
|
getSession: mockGetSession,
|
||||||
@@ -23,12 +20,7 @@ vi.mock('@/lib/workflows/utils', () => ({
|
|||||||
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@sim/db', () => ({
|
vi.mock('@sim/db', () => databaseMock)
|
||||||
db: {
|
|
||||||
select: mockDbSelect,
|
|
||||||
update: mockDbUpdate,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@sim/db/schema', () => ({
|
vi.mock('@sim/db/schema', () => ({
|
||||||
workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' },
|
workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' },
|
||||||
@@ -59,6 +51,9 @@ function createParams(id: string): { params: Promise<{ id: string }> } {
|
|||||||
return { params: Promise.resolve({ id }) }
|
return { params: Promise.resolve({ id }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mockDbSelect = databaseMock.db.select as ReturnType<typeof vi.fn>
|
||||||
|
const mockDbUpdate = databaseMock.db.update as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
function mockDbChain(selectResults: unknown[][]) {
|
function mockDbChain(selectResults: unknown[][]) {
|
||||||
let selectCallIndex = 0
|
let selectCallIndex = 0
|
||||||
mockDbSelect.mockImplementation(() => ({
|
mockDbSelect.mockImplementation(() => ({
|
||||||
|
|||||||
@@ -3,17 +3,14 @@
|
|||||||
*
|
*
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import { loggerMock } from '@sim/testing'
|
import { databaseMock, loggerMock } from '@sim/testing'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission, mockDbSelect } = vi.hoisted(
|
const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission } = vi.hoisted(() => ({
|
||||||
() => ({
|
mockGetSession: vi.fn(),
|
||||||
mockGetSession: vi.fn(),
|
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
|
||||||
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
|
}))
|
||||||
mockDbSelect: vi.fn(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth', () => ({
|
vi.mock('@/lib/auth', () => ({
|
||||||
getSession: mockGetSession,
|
getSession: mockGetSession,
|
||||||
@@ -23,11 +20,7 @@ vi.mock('@/lib/workflows/utils', () => ({
|
|||||||
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@sim/db', () => ({
|
vi.mock('@sim/db', () => databaseMock)
|
||||||
db: {
|
|
||||||
select: mockDbSelect,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@sim/db/schema', () => ({
|
vi.mock('@sim/db/schema', () => ({
|
||||||
workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' },
|
workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' },
|
||||||
@@ -62,6 +55,8 @@ function createRequest(url: string): NextRequest {
|
|||||||
return new NextRequest(new URL(url), { method: 'GET' })
|
return new NextRequest(new URL(url), { method: 'GET' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mockDbSelect = databaseMock.db.select as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
function mockDbChain(results: any[]) {
|
function mockDbChain(results: any[]) {
|
||||||
let callIndex = 0
|
let callIndex = 0
|
||||||
mockDbSelect.mockImplementation(() => ({
|
mockDbSelect.mockImplementation(() => ({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
|||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import {
|
import {
|
||||||
type RegenerateStateInput,
|
type RegenerateStateInput,
|
||||||
regenerateWorkflowStateIds,
|
regenerateWorkflowStateIds,
|
||||||
@@ -115,15 +115,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
// Step 3: Save the workflow state using the existing state endpoint (like imports do)
|
// Step 3: Save the workflow state using the existing state endpoint (like imports do)
|
||||||
// Ensure variables in state are remapped for the new workflow as well
|
// Ensure variables in state are remapped for the new workflow as well
|
||||||
const workflowStateWithVariables = { ...workflowState, variables: remappedVariables }
|
const workflowStateWithVariables = { ...workflowState, variables: remappedVariables }
|
||||||
const stateResponse = await fetch(`${getBaseUrl()}/api/workflows/${newWorkflowId}/state`, {
|
const stateResponse = await fetch(
|
||||||
method: 'PUT',
|
`${getInternalApiBaseUrl()}/api/workflows/${newWorkflowId}/state`,
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json',
|
method: 'PUT',
|
||||||
// Forward the session cookie for authentication
|
headers: {
|
||||||
cookie: request.headers.get('cookie') || '',
|
'Content-Type': 'application/json',
|
||||||
},
|
// Forward the session cookie for authentication
|
||||||
body: JSON.stringify(workflowStateWithVariables),
|
cookie: request.headers.get('cookie') || '',
|
||||||
})
|
},
|
||||||
|
body: JSON.stringify(workflowStateWithVariables),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if (!stateResponse.ok) {
|
if (!stateResponse.ok) {
|
||||||
logger.error(`[${requestId}] Failed to save workflow state for template use`)
|
logger.error(`[${requestId}] Failed to save workflow state for template use`)
|
||||||
|
|||||||
@@ -38,45 +38,6 @@ const createBlogPostSchema = z.object({
|
|||||||
status: z.enum(['current', 'draft']).optional(),
|
status: z.enum(['current', 'draft']).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateBlogPostSchema = z
|
|
||||||
.object({
|
|
||||||
domain: z.string().min(1, 'Domain is required'),
|
|
||||||
accessToken: z.string().min(1, 'Access token is required'),
|
|
||||||
cloudId: z.string().optional(),
|
|
||||||
blogPostId: z.string().min(1, 'Blog post ID is required'),
|
|
||||||
title: z.string().optional(),
|
|
||||||
content: z.string().optional(),
|
|
||||||
status: z.enum(['current', 'draft']).optional(),
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
|
|
||||||
return validation.isValid
|
|
||||||
},
|
|
||||||
(data) => {
|
|
||||||
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
|
|
||||||
return { message: validation.error || 'Invalid blog post ID', path: ['blogPostId'] }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const deleteBlogPostSchema = z
|
|
||||||
.object({
|
|
||||||
domain: z.string().min(1, 'Domain is required'),
|
|
||||||
accessToken: z.string().min(1, 'Access token is required'),
|
|
||||||
cloudId: z.string().optional(),
|
|
||||||
blogPostId: z.string().min(1, 'Blog post ID is required'),
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
|
|
||||||
return validation.isValid
|
|
||||||
},
|
|
||||||
(data) => {
|
|
||||||
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
|
|
||||||
return { message: validation.error || 'Invalid blog post ID', path: ['blogPostId'] }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all blog posts or get a specific blog post
|
* List all blog posts or get a specific blog post
|
||||||
*/
|
*/
|
||||||
@@ -322,174 +283,3 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a blog post
|
|
||||||
*/
|
|
||||||
export async function PUT(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const auth = await checkSessionOrInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
|
||||||
|
|
||||||
const validation = updateBlogPostSchema.safeParse(body)
|
|
||||||
if (!validation.success) {
|
|
||||||
const firstError = validation.error.errors[0]
|
|
||||||
return NextResponse.json({ error: firstError.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
domain,
|
|
||||||
accessToken,
|
|
||||||
cloudId: providedCloudId,
|
|
||||||
blogPostId,
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
status,
|
|
||||||
} = validation.data
|
|
||||||
|
|
||||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
|
||||||
|
|
||||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
|
||||||
if (!cloudIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const blogPostUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}`
|
|
||||||
|
|
||||||
const currentResponse = await fetch(blogPostUrl, {
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!currentResponse.ok) {
|
|
||||||
const errorData = await currentResponse.json().catch(() => null)
|
|
||||||
const errorMessage =
|
|
||||||
errorData?.message || `Failed to fetch blog post for update (${currentResponse.status})`
|
|
||||||
return NextResponse.json({ error: errorMessage }, { status: currentResponse.status })
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPost = await currentResponse.json()
|
|
||||||
const currentVersion = currentPost.version.number
|
|
||||||
|
|
||||||
const updateBody: Record<string, unknown> = {
|
|
||||||
id: blogPostId,
|
|
||||||
version: {
|
|
||||||
number: currentVersion + 1,
|
|
||||||
message: 'Updated via Sim',
|
|
||||||
},
|
|
||||||
status: status || currentPost.status || 'current',
|
|
||||||
title: title || currentPost.title,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content) {
|
|
||||||
updateBody.body = {
|
|
||||||
representation: 'storage',
|
|
||||||
value: content,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(blogPostUrl, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(updateBody),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => null)
|
|
||||||
logger.error('Confluence API error response:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: JSON.stringify(errorData, null, 2),
|
|
||||||
})
|
|
||||||
const errorMessage = errorData?.message || `Failed to update blog post (${response.status})`
|
|
||||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return NextResponse.json({
|
|
||||||
id: data.id,
|
|
||||||
title: data.title,
|
|
||||||
status: data.status ?? null,
|
|
||||||
spaceId: data.spaceId ?? null,
|
|
||||||
authorId: data.authorId ?? null,
|
|
||||||
createdAt: data.createdAt ?? null,
|
|
||||||
version: data.version ?? null,
|
|
||||||
body: data.body ?? null,
|
|
||||||
webUrl: data._links?.webui ?? null,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error updating blog post:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message || 'Internal server error' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a blog post
|
|
||||||
*/
|
|
||||||
export async function DELETE(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const auth = await checkSessionOrInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
|
||||||
|
|
||||||
const validation = deleteBlogPostSchema.safeParse(body)
|
|
||||||
if (!validation.success) {
|
|
||||||
const firstError = validation.error.errors[0]
|
|
||||||
return NextResponse.json({ error: firstError.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { domain, accessToken, cloudId: providedCloudId, blogPostId } = validation.data
|
|
||||||
|
|
||||||
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
|
|
||||||
|
|
||||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
|
||||||
if (!cloudIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}`
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => null)
|
|
||||||
logger.error('Confluence API error response:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: JSON.stringify(errorData, null, 2),
|
|
||||||
})
|
|
||||||
const errorMessage = errorData?.message || `Failed to delete blog post (${response.status})`
|
|
||||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ blogPostId, deleted: true })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error deleting blog post:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message || 'Internal server error' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
|
|
||||||
import {
|
|
||||||
downloadJsmAttachments,
|
|
||||||
getJiraCloudId,
|
|
||||||
getJsmApiBaseUrl,
|
|
||||||
getJsmHeaders,
|
|
||||||
} from '@/tools/jsm/utils'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
const logger = createLogger('JsmAttachmentsAPI')
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const auth = await checkInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const {
|
|
||||||
domain,
|
|
||||||
accessToken,
|
|
||||||
cloudId: cloudIdParam,
|
|
||||||
issueIdOrKey,
|
|
||||||
includeAttachments,
|
|
||||||
start,
|
|
||||||
limit,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
logger.error('Missing domain in request')
|
|
||||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
logger.error('Missing access token in request')
|
|
||||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!issueIdOrKey) {
|
|
||||||
logger.error('Missing issueIdOrKey in request')
|
|
||||||
return NextResponse.json({ error: 'Issue ID or key is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
|
|
||||||
if (!issueIdOrKeyValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
|
||||||
|
|
||||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
|
||||||
if (!cloudIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = getJsmApiBaseUrl(cloudId)
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
if (start) params.append('start', start)
|
|
||||||
if (limit) params.append('limit', limit)
|
|
||||||
|
|
||||||
const url = `${baseUrl}/request/${issueIdOrKey}/attachment${params.toString() ? `?${params.toString()}` : ''}`
|
|
||||||
|
|
||||||
logger.info('Fetching request attachments from:', url)
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
const rawAttachments = data.values || []
|
|
||||||
|
|
||||||
const attachments = rawAttachments.map((att: Record<string, unknown>) => ({
|
|
||||||
filename: att.filename ?? '',
|
|
||||||
author: att.author
|
|
||||||
? {
|
|
||||||
accountId: (att.author as Record<string, unknown>).accountId ?? '',
|
|
||||||
displayName: (att.author as Record<string, unknown>).displayName ?? '',
|
|
||||||
active: (att.author as Record<string, unknown>).active ?? true,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
created: att.created ?? null,
|
|
||||||
size: att.size ?? 0,
|
|
||||||
mimeType: att.mimeType ?? '',
|
|
||||||
}))
|
|
||||||
|
|
||||||
let files: Array<{ name: string; mimeType: string; data: string; size: number }> | undefined
|
|
||||||
|
|
||||||
if (includeAttachments && rawAttachments.length > 0) {
|
|
||||||
const downloadable = rawAttachments
|
|
||||||
.filter((att: Record<string, unknown>) => {
|
|
||||||
const links = att._links as Record<string, string> | undefined
|
|
||||||
return links?.content
|
|
||||||
})
|
|
||||||
.map((att: Record<string, unknown>) => ({
|
|
||||||
contentUrl: (att._links as Record<string, string>).content as string,
|
|
||||||
filename: (att.filename as string) ?? '',
|
|
||||||
mimeType: (att.mimeType as string) ?? '',
|
|
||||||
size: (att.size as number) ?? 0,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (downloadable.length > 0) {
|
|
||||||
files = await downloadJsmAttachments(downloadable, accessToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
issueIdOrKey,
|
|
||||||
attachments,
|
|
||||||
total: data.size || 0,
|
|
||||||
isLastPage: data.isLastPage ?? true,
|
|
||||||
...(files && files.length > 0 ? { files } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error fetching attachments:', {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: error instanceof Error ? error.message : 'Internal server error',
|
|
||||||
success: false,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
|
|
||||||
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
const logger = createLogger('JsmCustomerAPI')
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const auth = await checkInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const { domain, accessToken, cloudId: cloudIdParam, email, displayName } = body
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
logger.error('Missing domain in request')
|
|
||||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
logger.error('Missing access token in request')
|
|
||||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
logger.error('Missing email in request')
|
|
||||||
return NextResponse.json({ error: 'Email is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!displayName) {
|
|
||||||
logger.error('Missing displayName in request')
|
|
||||||
return NextResponse.json({ error: 'Display name is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
|
||||||
|
|
||||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
|
||||||
if (!cloudIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = getJsmApiBaseUrl(cloudId)
|
|
||||||
const url = `${baseUrl}/customer`
|
|
||||||
|
|
||||||
logger.info('Creating customer:', { email, displayName })
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
body: JSON.stringify({ email, displayName }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
accountId: data.accountId ?? '',
|
|
||||||
displayName: data.displayName ?? '',
|
|
||||||
emailAddress: data.emailAddress ?? '',
|
|
||||||
active: data.active ?? true,
|
|
||||||
timeZone: data.timeZone ?? null,
|
|
||||||
success: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error creating customer:', {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: error instanceof Error ? error.message : 'Internal server error',
|
|
||||||
success: false,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -57,8 +57,6 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const baseUrl = getJsmApiBaseUrl(cloudId)
|
const baseUrl = getJsmApiBaseUrl(cloudId)
|
||||||
|
|
||||||
const { action: customerAction } = body
|
|
||||||
|
|
||||||
const rawIds = accountIds || emails
|
const rawIds = accountIds || emails
|
||||||
const parsedAccountIds = rawIds
|
const parsedAccountIds = rawIds
|
||||||
? typeof rawIds === 'string'
|
? typeof rawIds === 'string'
|
||||||
@@ -71,50 +69,7 @@ export async function POST(request: NextRequest) {
|
|||||||
: []
|
: []
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const isRemoveOperation = customerAction === 'remove'
|
const isAddOperation = parsedAccountIds.length > 0
|
||||||
const isAddOperation = !isRemoveOperation && parsedAccountIds.length > 0
|
|
||||||
|
|
||||||
if (isRemoveOperation) {
|
|
||||||
if (parsedAccountIds.length === 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Account IDs or emails are required for removal' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${baseUrl}/servicedesk/${serviceDeskId}/customer`
|
|
||||||
|
|
||||||
logger.info('Removing customers from:', url, { accountIds: parsedAccountIds })
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
body: JSON.stringify({ accountIds: parsedAccountIds }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 204 || response.ok) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
serviceDeskId,
|
|
||||||
success: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAddOperation) {
|
if (isAddOperation) {
|
||||||
const url = `${baseUrl}/servicedesk/${serviceDeskId}/customer`
|
const url = `${baseUrl}/servicedesk/${serviceDeskId}/customer`
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import {
|
|
||||||
validateEnum,
|
|
||||||
validateJiraCloudId,
|
|
||||||
validateJiraIssueKey,
|
|
||||||
} from '@/lib/core/security/input-validation'
|
|
||||||
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
const logger = createLogger('JsmFeedbackAPI')
|
|
||||||
|
|
||||||
const VALID_ACTIONS = ['get', 'add', 'delete'] as const
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const auth = await checkInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const {
|
|
||||||
domain,
|
|
||||||
accessToken,
|
|
||||||
cloudId: cloudIdParam,
|
|
||||||
action,
|
|
||||||
issueIdOrKey,
|
|
||||||
rating,
|
|
||||||
comment,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
logger.error('Missing domain in request')
|
|
||||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
logger.error('Missing access token in request')
|
|
||||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!issueIdOrKey) {
|
|
||||||
logger.error('Missing issueIdOrKey in request')
|
|
||||||
return NextResponse.json({ error: 'Issue ID or key is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!action) {
|
|
||||||
logger.error('Missing action in request')
|
|
||||||
return NextResponse.json({ error: 'Action is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionValidation = validateEnum(action, VALID_ACTIONS, 'action')
|
|
||||||
if (!actionValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: actionValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
|
|
||||||
if (!issueIdOrKeyValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
|
||||||
|
|
||||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
|
||||||
if (!cloudIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = getJsmApiBaseUrl(cloudId)
|
|
||||||
const url = `${baseUrl}/request/${issueIdOrKey}/feedback`
|
|
||||||
|
|
||||||
if (action === 'get') {
|
|
||||||
logger.info('Fetching feedback from:', url)
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `JSM API error: ${response.status} ${response.statusText}`,
|
|
||||||
details: errorText,
|
|
||||||
},
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
issueIdOrKey,
|
|
||||||
rating: data.rating ?? null,
|
|
||||||
comment: data.comment?.body ?? null,
|
|
||||||
type: data.type ?? null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'add') {
|
|
||||||
if (rating === undefined || rating === null) {
|
|
||||||
logger.error('Missing rating in request')
|
|
||||||
return NextResponse.json({ error: 'Rating is required (1-5)' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Adding feedback to:', url, { rating })
|
|
||||||
|
|
||||||
const feedbackBody: Record<string, unknown> = {
|
|
||||||
rating: Number(rating),
|
|
||||||
type: 'csat',
|
|
||||||
}
|
|
||||||
|
|
||||||
if (comment) {
|
|
||||||
feedbackBody.comment = { body: comment }
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
body: JSON.stringify(feedbackBody),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `JSM API error: ${response.status} ${response.statusText}`,
|
|
||||||
details: errorText,
|
|
||||||
},
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
issueIdOrKey,
|
|
||||||
rating: data.rating ?? Number(rating),
|
|
||||||
comment: data.comment?.body ?? comment ?? null,
|
|
||||||
type: data.type ?? 'csat',
|
|
||||||
success: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'delete') {
|
|
||||||
logger.info('Deleting feedback from:', url)
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 204 || response.ok) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
issueIdOrKey,
|
|
||||||
success: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `JSM API error: ${response.status} ${response.statusText}`,
|
|
||||||
details: errorText,
|
|
||||||
},
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error in feedback operation:', {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: error instanceof Error ? error.message : 'Internal server error',
|
|
||||||
success: false,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
|
||||||
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
const logger = createLogger('JsmKnowledgeBaseAPI')
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const auth = await checkInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const {
|
|
||||||
domain,
|
|
||||||
accessToken,
|
|
||||||
cloudId: cloudIdParam,
|
|
||||||
serviceDeskId,
|
|
||||||
query,
|
|
||||||
highlight,
|
|
||||||
start,
|
|
||||||
limit,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
logger.error('Missing domain in request')
|
|
||||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
logger.error('Missing access token in request')
|
|
||||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
logger.error('Missing query in request')
|
|
||||||
return NextResponse.json({ error: 'Search query is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (serviceDeskId) {
|
|
||||||
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
|
|
||||||
if (!serviceDeskIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
|
||||||
|
|
||||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
|
||||||
if (!cloudIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = getJsmApiBaseUrl(cloudId)
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
params.append('query', query)
|
|
||||||
if (highlight !== undefined) params.append('highlight', String(highlight))
|
|
||||||
if (start) params.append('start', start)
|
|
||||||
if (limit) params.append('limit', limit)
|
|
||||||
|
|
||||||
const basePath = serviceDeskId
|
|
||||||
? `${baseUrl}/servicedesk/${serviceDeskId}/knowledgebase/article`
|
|
||||||
: `${baseUrl}/knowledgebase/article`
|
|
||||||
|
|
||||||
const url = `${basePath}?${params.toString()}`
|
|
||||||
|
|
||||||
logger.info('Searching knowledge base:', url)
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
const articles = (data.values || []).map((article: Record<string, unknown>) => ({
|
|
||||||
title: (article.title as string) ?? '',
|
|
||||||
excerpt: (article.excerpt as string) ?? '',
|
|
||||||
sourceType: (article.source as Record<string, unknown>)?.type ?? '',
|
|
||||||
sourcePageId: (article.source as Record<string, unknown>)?.pageId ?? null,
|
|
||||||
sourceSpaceKey: (article.source as Record<string, unknown>)?.spaceKey ?? null,
|
|
||||||
contentUrl: (article.content as Record<string, unknown>)?.iframeSrc ?? null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
articles,
|
|
||||||
total: data.size || 0,
|
|
||||||
isLastPage: data.isLastPage ?? true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error searching knowledge base:', {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: error instanceof Error ? error.message : 'Internal server error',
|
|
||||||
success: false,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import {
|
|
||||||
validateEnum,
|
|
||||||
validateJiraCloudId,
|
|
||||||
validateJiraIssueKey,
|
|
||||||
} from '@/lib/core/security/input-validation'
|
|
||||||
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
const logger = createLogger('JsmNotificationAPI')
|
|
||||||
|
|
||||||
const VALID_ACTIONS = ['get', 'subscribe', 'unsubscribe'] as const
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const auth = await checkInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const { domain, accessToken, cloudId: cloudIdParam, action, issueIdOrKey } = body
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
logger.error('Missing domain in request')
|
|
||||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
logger.error('Missing access token in request')
|
|
||||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!issueIdOrKey) {
|
|
||||||
logger.error('Missing issueIdOrKey in request')
|
|
||||||
return NextResponse.json({ error: 'Issue ID or key is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!action) {
|
|
||||||
logger.error('Missing action in request')
|
|
||||||
return NextResponse.json({ error: 'Action is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionValidation = validateEnum(action, VALID_ACTIONS, 'action')
|
|
||||||
if (!actionValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: actionValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
|
|
||||||
if (!issueIdOrKeyValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
|
||||||
|
|
||||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
|
||||||
if (!cloudIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = getJsmApiBaseUrl(cloudId)
|
|
||||||
const url = `${baseUrl}/request/${issueIdOrKey}/notification`
|
|
||||||
|
|
||||||
if (action === 'get') {
|
|
||||||
logger.info('Fetching notification status from:', url)
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `JSM API error: ${response.status} ${response.statusText}`,
|
|
||||||
details: errorText,
|
|
||||||
},
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
issueIdOrKey,
|
|
||||||
subscribed: data.subscribed ?? false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'subscribe') {
|
|
||||||
logger.info('Subscribing to notifications:', url)
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 204 || response.ok) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
issueIdOrKey,
|
|
||||||
success: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `JSM API error: ${response.status} ${response.statusText}`,
|
|
||||||
details: errorText,
|
|
||||||
},
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'unsubscribe') {
|
|
||||||
logger.info('Unsubscribing from notifications:', url)
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 204 || response.ok) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
issueIdOrKey,
|
|
||||||
success: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `JSM API error: ${response.status} ${response.statusText}`,
|
|
||||||
details: errorText,
|
|
||||||
},
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error in notification operation:', {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: error instanceof Error ? error.message : 'Internal server error',
|
|
||||||
success: false,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,13 +12,7 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
const logger = createLogger('JsmOrganizationAPI')
|
const logger = createLogger('JsmOrganizationAPI')
|
||||||
|
|
||||||
const VALID_ACTIONS = [
|
const VALID_ACTIONS = ['create', 'add_to_service_desk'] as const
|
||||||
'create',
|
|
||||||
'add_to_service_desk',
|
|
||||||
'remove_from_service_desk',
|
|
||||||
'delete',
|
|
||||||
'get',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const auth = await checkInternalAuth(request)
|
const auth = await checkInternalAuth(request)
|
||||||
@@ -165,152 +159,6 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'remove_from_service_desk') {
|
|
||||||
if (!serviceDeskId) {
|
|
||||||
logger.error('Missing serviceDeskId in request')
|
|
||||||
return NextResponse.json({ error: 'Service Desk ID is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!organizationId) {
|
|
||||||
logger.error('Missing organizationId in request')
|
|
||||||
return NextResponse.json({ error: 'Organization ID is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
|
|
||||||
if (!serviceDeskIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const organizationIdValidation = validateAlphanumericId(organizationId, 'organizationId')
|
|
||||||
if (!organizationIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: organizationIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${baseUrl}/servicedesk/${serviceDeskId}/organization`
|
|
||||||
|
|
||||||
logger.info('Removing organization from service desk:', { serviceDeskId, organizationId })
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
body: JSON.stringify({ organizationId: Number.parseInt(organizationId, 10) }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 204 || response.ok) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
serviceDeskId,
|
|
||||||
organizationId,
|
|
||||||
success: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'delete') {
|
|
||||||
if (!organizationId) {
|
|
||||||
logger.error('Missing organizationId in request')
|
|
||||||
return NextResponse.json({ error: 'Organization ID is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const organizationIdValidation = validateAlphanumericId(organizationId, 'organizationId')
|
|
||||||
if (!organizationIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: organizationIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${baseUrl}/organization/${organizationId}`
|
|
||||||
|
|
||||||
logger.info('Deleting organization:', { organizationId })
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 204 || response.ok) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
organizationId,
|
|
||||||
success: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'get') {
|
|
||||||
if (!organizationId) {
|
|
||||||
logger.error('Missing organizationId in request')
|
|
||||||
return NextResponse.json({ error: 'Organization ID is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const organizationIdValidation = validateAlphanumericId(organizationId, 'organizationId')
|
|
||||||
if (!organizationIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: organizationIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${baseUrl}/organization/${organizationId}`
|
|
||||||
|
|
||||||
logger.info('Fetching organization:', { organizationId })
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
id: data.id ?? '',
|
|
||||||
name: data.name ?? '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error in organization operation:', {
|
logger.error('Error in organization operation:', {
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import {
|
|
||||||
validateAlphanumericId,
|
|
||||||
validateEnum,
|
|
||||||
validateJiraCloudId,
|
|
||||||
} from '@/lib/core/security/input-validation'
|
|
||||||
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
const logger = createLogger('JsmOrganizationUsersAPI')
|
|
||||||
|
|
||||||
const VALID_ACTIONS = ['get', 'add', 'remove'] as const
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const auth = await checkInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const {
|
|
||||||
domain,
|
|
||||||
accessToken,
|
|
||||||
cloudId: cloudIdParam,
|
|
||||||
action,
|
|
||||||
organizationId,
|
|
||||||
accountIds,
|
|
||||||
start,
|
|
||||||
limit,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
logger.error('Missing domain in request')
|
|
||||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
logger.error('Missing access token in request')
|
|
||||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!organizationId) {
|
|
||||||
logger.error('Missing organizationId in request')
|
|
||||||
return NextResponse.json({ error: 'Organization ID is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!action) {
|
|
||||||
logger.error('Missing action in request')
|
|
||||||
return NextResponse.json({ error: 'Action is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionValidation = validateEnum(action, VALID_ACTIONS, 'action')
|
|
||||||
if (!actionValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: actionValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const organizationIdValidation = validateAlphanumericId(organizationId, 'organizationId')
|
|
||||||
if (!organizationIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: organizationIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
|
||||||
|
|
||||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
|
||||||
if (!cloudIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = getJsmApiBaseUrl(cloudId)
|
|
||||||
const url = `${baseUrl}/organization/${organizationId}/user`
|
|
||||||
|
|
||||||
if (action === 'get') {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
if (start) params.append('start', start)
|
|
||||||
if (limit) params.append('limit', limit)
|
|
||||||
|
|
||||||
const getUrl = `${url}${params.toString() ? `?${params.toString()}` : ''}`
|
|
||||||
|
|
||||||
logger.info('Fetching organization users from:', getUrl)
|
|
||||||
|
|
||||||
const response = await fetch(getUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `JSM API error: ${response.status} ${response.statusText}`,
|
|
||||||
details: errorText,
|
|
||||||
},
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
organizationId,
|
|
||||||
users: data.values || [],
|
|
||||||
total: data.size || 0,
|
|
||||||
isLastPage: data.isLastPage ?? true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'add' || action === 'remove') {
|
|
||||||
if (!accountIds) {
|
|
||||||
logger.error('Missing accountIds in request')
|
|
||||||
return NextResponse.json({ error: 'Account IDs are required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedAccountIds =
|
|
||||||
typeof accountIds === 'string'
|
|
||||||
? accountIds
|
|
||||||
.split(',')
|
|
||||||
.map((id: string) => id.trim())
|
|
||||||
.filter((id: string) => id)
|
|
||||||
: accountIds
|
|
||||||
|
|
||||||
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} organization users:`, {
|
|
||||||
organizationId,
|
|
||||||
accountIds: parsedAccountIds,
|
|
||||||
})
|
|
||||||
|
|
||||||
const method = action === 'add' ? 'POST' : 'DELETE'
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
body: JSON.stringify({ accountIds: parsedAccountIds }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 204 || response.ok) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
organizationId,
|
|
||||||
success: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `JSM API error: ${response.status} ${response.statusText}`,
|
|
||||||
details: errorText,
|
|
||||||
},
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error in organization users operation:', {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: error instanceof Error ? error.message : 'Internal server error',
|
|
||||||
success: false,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
const logger = createLogger('JsmParticipantsAPI')
|
const logger = createLogger('JsmParticipantsAPI')
|
||||||
|
|
||||||
const VALID_ACTIONS = ['get', 'add', 'remove'] as const
|
const VALID_ACTIONS = ['get', 'add'] as const
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const auth = await checkInternalAuth(request)
|
const auth = await checkInternalAuth(request)
|
||||||
@@ -113,7 +113,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (action === 'add' || action === 'remove') {
|
if (action === 'add') {
|
||||||
if (!accountIds) {
|
if (!accountIds) {
|
||||||
logger.error('Missing accountIds in request')
|
logger.error('Missing accountIds in request')
|
||||||
return NextResponse.json({ error: 'Account IDs are required' }, { status: 400 })
|
return NextResponse.json({ error: 'Account IDs are required' }, { status: 400 })
|
||||||
@@ -128,19 +128,16 @@ export async function POST(request: NextRequest) {
|
|||||||
: accountIds
|
: accountIds
|
||||||
|
|
||||||
const url = `${baseUrl}/request/${issueIdOrKey}/participant`
|
const url = `${baseUrl}/request/${issueIdOrKey}/participant`
|
||||||
const method = action === 'add' ? 'POST' : 'DELETE'
|
|
||||||
|
|
||||||
logger.info(`${action === 'add' ? 'Adding' : 'Removing'} participants:`, url, {
|
logger.info('Adding participants to:', url, { accountIds: parsedAccountIds })
|
||||||
accountIds: parsedAccountIds,
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method: 'POST',
|
||||||
headers: getJsmHeaders(accessToken),
|
headers: getJsmHeaders(accessToken),
|
||||||
body: JSON.stringify({ accountIds: parsedAccountIds }),
|
body: JSON.stringify({ accountIds: parsedAccountIds }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok && response.status !== 204) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text()
|
const errorText = await response.text()
|
||||||
logger.error('JSM API error:', {
|
logger.error('JSM API error:', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
@@ -154,22 +151,14 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let participants: unknown[] = []
|
const data = await response.json()
|
||||||
if (response.status !== 204) {
|
|
||||||
try {
|
|
||||||
const data = await response.json()
|
|
||||||
participants = data.values || []
|
|
||||||
} catch {
|
|
||||||
// DELETE may return empty body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
output: {
|
output: {
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
issueIdOrKey,
|
issueIdOrKey,
|
||||||
participants,
|
participants: data.values || [],
|
||||||
success: true,
|
success: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
|
||||||
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
const logger = createLogger('JsmQueueIssuesAPI')
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const auth = await checkInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const {
|
|
||||||
domain,
|
|
||||||
accessToken,
|
|
||||||
cloudId: cloudIdParam,
|
|
||||||
serviceDeskId,
|
|
||||||
queueId,
|
|
||||||
start,
|
|
||||||
limit,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
logger.error('Missing domain in request')
|
|
||||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
logger.error('Missing access token in request')
|
|
||||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!serviceDeskId) {
|
|
||||||
logger.error('Missing serviceDeskId in request')
|
|
||||||
return NextResponse.json({ error: 'Service Desk ID is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!queueId) {
|
|
||||||
logger.error('Missing queueId in request')
|
|
||||||
return NextResponse.json({ error: 'Queue ID is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
|
|
||||||
if (!serviceDeskIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const queueIdValidation = validateAlphanumericId(queueId, 'queueId')
|
|
||||||
if (!queueIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: queueIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
|
||||||
|
|
||||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
|
||||||
if (!cloudIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = getJsmApiBaseUrl(cloudId)
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
if (start) params.append('start', start)
|
|
||||||
if (limit) params.append('limit', limit)
|
|
||||||
|
|
||||||
const url = `${baseUrl}/servicedesk/${serviceDeskId}/queue/${queueId}/issue${params.toString() ? `?${params.toString()}` : ''}`
|
|
||||||
|
|
||||||
logger.info('Fetching queue issues from:', url)
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
serviceDeskId,
|
|
||||||
queueId,
|
|
||||||
issues: data.values || [],
|
|
||||||
total: data.size || 0,
|
|
||||||
isLastPage: data.isLastPage ?? true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error fetching queue issues:', {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: error instanceof Error ? error.message : 'Internal server error',
|
|
||||||
success: false,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
|
||||||
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
|
|
||||||
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
const logger = createLogger('JsmRequestStatusAPI')
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const auth = await checkInternalAuth(request)
|
|
||||||
if (!auth.success || !auth.userId) {
|
|
||||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, start, limit } = body
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
logger.error('Missing domain in request')
|
|
||||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
logger.error('Missing access token in request')
|
|
||||||
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!issueIdOrKey) {
|
|
||||||
logger.error('Missing issueIdOrKey in request')
|
|
||||||
return NextResponse.json({ error: 'Issue ID or key is required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
|
|
||||||
if (!issueIdOrKeyValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
|
|
||||||
|
|
||||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
|
||||||
if (!cloudIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = getJsmApiBaseUrl(cloudId)
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
if (start) params.append('start', start)
|
|
||||||
if (limit) params.append('limit', limit)
|
|
||||||
|
|
||||||
const url = `${baseUrl}/request/${issueIdOrKey}/status${params.toString() ? `?${params.toString()}` : ''}`
|
|
||||||
|
|
||||||
logger.info('Fetching request status history from:', url)
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
issueIdOrKey,
|
|
||||||
statuses: data.values || [],
|
|
||||||
total: data.size || 0,
|
|
||||||
isLastPage: data.isLastPage ?? true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error fetching request status:', {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: error instanceof Error ? error.message : 'Internal server error',
|
|
||||||
success: false,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||||
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -16,16 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const {
|
const { domain, accessToken, cloudId: cloudIdParam, expand, start, limit } = body
|
||||||
domain,
|
|
||||||
accessToken,
|
|
||||||
cloudId: cloudIdParam,
|
|
||||||
expand,
|
|
||||||
start,
|
|
||||||
limit,
|
|
||||||
serviceDeskId,
|
|
||||||
action,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
logger.error('Missing domain in request')
|
logger.error('Missing domain in request')
|
||||||
@@ -46,52 +37,6 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const baseUrl = getJsmApiBaseUrl(cloudId)
|
const baseUrl = getJsmApiBaseUrl(cloudId)
|
||||||
|
|
||||||
if (action === 'get' && serviceDeskId) {
|
|
||||||
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
|
|
||||||
if (!serviceDeskIdValidation.isValid) {
|
|
||||||
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${baseUrl}/servicedesk/${serviceDeskId}`
|
|
||||||
|
|
||||||
logger.info('Fetching service desk:', url)
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getJsmHeaders(accessToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
logger.error('JSM API error:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorText,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
output: {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
id: data.id ?? '',
|
|
||||||
projectId: data.projectId ?? '',
|
|
||||||
projectName: data.projectName ?? '',
|
|
||||||
projectKey: data.projectKey ?? '',
|
|
||||||
name: data.projectName ?? '',
|
|
||||||
description: data.description ?? null,
|
|
||||||
leadDisplayName: data.leadDisplayName ?? null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (expand) params.append('expand', expand)
|
if (expand) params.append('expand', expand)
|
||||||
if (start) params.append('start', start)
|
if (start) params.append('start', start)
|
||||||
|
|||||||
@@ -66,6 +66,12 @@
|
|||||||
* Credits:
|
* Credits:
|
||||||
* POST /api/v1/admin/credits - Issue credits to user (by userId or email)
|
* POST /api/v1/admin/credits - Issue credits to user (by userId or email)
|
||||||
*
|
*
|
||||||
|
* Referral Campaigns:
|
||||||
|
* GET /api/v1/admin/referral-campaigns - List campaigns (?active=true/false)
|
||||||
|
* POST /api/v1/admin/referral-campaigns - Create campaign
|
||||||
|
* GET /api/v1/admin/referral-campaigns/:id - Get campaign details
|
||||||
|
* PATCH /api/v1/admin/referral-campaigns/:id - Update campaign fields
|
||||||
|
*
|
||||||
* Access Control (Permission Groups):
|
* Access Control (Permission Groups):
|
||||||
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
|
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
|
||||||
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)
|
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)
|
||||||
@@ -97,6 +103,7 @@ export type {
|
|||||||
AdminOrganization,
|
AdminOrganization,
|
||||||
AdminOrganizationBillingSummary,
|
AdminOrganizationBillingSummary,
|
||||||
AdminOrganizationDetail,
|
AdminOrganizationDetail,
|
||||||
|
AdminReferralCampaign,
|
||||||
AdminSeatAnalytics,
|
AdminSeatAnalytics,
|
||||||
AdminSingleResponse,
|
AdminSingleResponse,
|
||||||
AdminSubscription,
|
AdminSubscription,
|
||||||
@@ -111,6 +118,7 @@ export type {
|
|||||||
AdminWorkspaceMember,
|
AdminWorkspaceMember,
|
||||||
DbMember,
|
DbMember,
|
||||||
DbOrganization,
|
DbOrganization,
|
||||||
|
DbReferralCampaign,
|
||||||
DbSubscription,
|
DbSubscription,
|
||||||
DbUser,
|
DbUser,
|
||||||
DbUserStats,
|
DbUserStats,
|
||||||
@@ -139,6 +147,7 @@ export {
|
|||||||
parseWorkflowVariables,
|
parseWorkflowVariables,
|
||||||
toAdminFolder,
|
toAdminFolder,
|
||||||
toAdminOrganization,
|
toAdminOrganization,
|
||||||
|
toAdminReferralCampaign,
|
||||||
toAdminSubscription,
|
toAdminSubscription,
|
||||||
toAdminUser,
|
toAdminUser,
|
||||||
toAdminWorkflow,
|
toAdminWorkflow,
|
||||||
|
|||||||
142
apps/sim/app/api/v1/admin/referral-campaigns/[id]/route.ts
Normal file
142
apps/sim/app/api/v1/admin/referral-campaigns/[id]/route.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/v1/admin/referral-campaigns/:id
|
||||||
|
*
|
||||||
|
* Get a single referral campaign by ID.
|
||||||
|
*
|
||||||
|
* PATCH /api/v1/admin/referral-campaigns/:id
|
||||||
|
*
|
||||||
|
* Update campaign fields. All fields are optional.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - name: string (non-empty) - Campaign name
|
||||||
|
* - bonusCreditAmount: number (> 0) - Bonus credits in dollars
|
||||||
|
* - isActive: boolean - Enable/disable the campaign
|
||||||
|
* - code: string | null (min 6 chars, auto-uppercased, null to remove) - Redeemable code
|
||||||
|
* - utmSource: string | null - UTM source match (null = wildcard)
|
||||||
|
* - utmMedium: string | null - UTM medium match (null = wildcard)
|
||||||
|
* - utmCampaign: string | null - UTM campaign match (null = wildcard)
|
||||||
|
* - utmContent: string | null - UTM content match (null = wildcard)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '@sim/db'
|
||||||
|
import { referralCampaigns } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
|
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||||
|
import {
|
||||||
|
badRequestResponse,
|
||||||
|
internalErrorResponse,
|
||||||
|
notFoundResponse,
|
||||||
|
singleResponse,
|
||||||
|
} from '@/app/api/v1/admin/responses'
|
||||||
|
import { toAdminReferralCampaign } from '@/app/api/v1/admin/types'
|
||||||
|
|
||||||
|
const logger = createLogger('AdminReferralCampaignDetailAPI')
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||||
|
try {
|
||||||
|
const { id: campaignId } = await context.params
|
||||||
|
|
||||||
|
const [campaign] = await db
|
||||||
|
.select()
|
||||||
|
.from(referralCampaigns)
|
||||||
|
.where(eq(referralCampaigns.id, campaignId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
return notFoundResponse('Campaign')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Admin API: Retrieved referral campaign ${campaignId}`)
|
||||||
|
|
||||||
|
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin API: Failed to get referral campaign', { error })
|
||||||
|
return internalErrorResponse('Failed to get referral campaign')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||||
|
try {
|
||||||
|
const { id: campaignId } = await context.params
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(referralCampaigns)
|
||||||
|
.where(eq(referralCampaigns.id, campaignId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return notFoundResponse('Campaign')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = { updatedAt: new Date() }
|
||||||
|
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
if (typeof body.name !== 'string' || body.name.trim().length === 0) {
|
||||||
|
return badRequestResponse('name must be a non-empty string')
|
||||||
|
}
|
||||||
|
updateData.name = body.name.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.bonusCreditAmount !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof body.bonusCreditAmount !== 'number' ||
|
||||||
|
!Number.isFinite(body.bonusCreditAmount) ||
|
||||||
|
body.bonusCreditAmount <= 0
|
||||||
|
) {
|
||||||
|
return badRequestResponse('bonusCreditAmount must be a positive number')
|
||||||
|
}
|
||||||
|
updateData.bonusCreditAmount = body.bonusCreditAmount.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.isActive !== undefined) {
|
||||||
|
if (typeof body.isActive !== 'boolean') {
|
||||||
|
return badRequestResponse('isActive must be a boolean')
|
||||||
|
}
|
||||||
|
updateData.isActive = body.isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.code !== undefined) {
|
||||||
|
if (body.code !== null) {
|
||||||
|
if (typeof body.code !== 'string') {
|
||||||
|
return badRequestResponse('code must be a string or null')
|
||||||
|
}
|
||||||
|
if (body.code.trim().length < 6) {
|
||||||
|
return badRequestResponse('code must be at least 6 characters')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateData.code = body.code ? body.code.trim().toUpperCase() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent'] as const) {
|
||||||
|
if (body[field] !== undefined) {
|
||||||
|
if (body[field] !== null && typeof body[field] !== 'string') {
|
||||||
|
return badRequestResponse(`${field} must be a string or null`)
|
||||||
|
}
|
||||||
|
updateData[field] = body[field] || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(referralCampaigns)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(referralCampaigns.id, campaignId))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
logger.info(`Admin API: Updated referral campaign ${campaignId}`, {
|
||||||
|
fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return singleResponse(toAdminReferralCampaign(updated, getBaseUrl()))
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin API: Failed to update referral campaign', { error })
|
||||||
|
return internalErrorResponse('Failed to update referral campaign')
|
||||||
|
}
|
||||||
|
})
|
||||||
140
apps/sim/app/api/v1/admin/referral-campaigns/route.ts
Normal file
140
apps/sim/app/api/v1/admin/referral-campaigns/route.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/v1/admin/referral-campaigns
|
||||||
|
*
|
||||||
|
* List referral campaigns with optional filtering and pagination.
|
||||||
|
*
|
||||||
|
* Query Parameters:
|
||||||
|
* - active: string (optional) - Filter by active status ('true' or 'false')
|
||||||
|
* - limit: number (default: 50, max: 250)
|
||||||
|
* - offset: number (default: 0)
|
||||||
|
*
|
||||||
|
* POST /api/v1/admin/referral-campaigns
|
||||||
|
*
|
||||||
|
* Create a new referral campaign.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - name: string (required) - Campaign name
|
||||||
|
* - bonusCreditAmount: number (required, > 0) - Bonus credits in dollars
|
||||||
|
* - code: string | null (optional, min 6 chars, auto-uppercased) - Redeemable code
|
||||||
|
* - utmSource: string | null (optional) - UTM source match (null = wildcard)
|
||||||
|
* - utmMedium: string | null (optional) - UTM medium match (null = wildcard)
|
||||||
|
* - utmCampaign: string | null (optional) - UTM campaign match (null = wildcard)
|
||||||
|
* - utmContent: string | null (optional) - UTM content match (null = wildcard)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '@sim/db'
|
||||||
|
import { referralCampaigns } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { count, eq, type SQL } from 'drizzle-orm'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
|
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||||
|
import {
|
||||||
|
badRequestResponse,
|
||||||
|
internalErrorResponse,
|
||||||
|
listResponse,
|
||||||
|
singleResponse,
|
||||||
|
} from '@/app/api/v1/admin/responses'
|
||||||
|
import {
|
||||||
|
type AdminReferralCampaign,
|
||||||
|
createPaginationMeta,
|
||||||
|
parsePaginationParams,
|
||||||
|
toAdminReferralCampaign,
|
||||||
|
} from '@/app/api/v1/admin/types'
|
||||||
|
|
||||||
|
const logger = createLogger('AdminReferralCampaignsAPI')
|
||||||
|
|
||||||
|
export const GET = withAdminAuth(async (request) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const { limit, offset } = parsePaginationParams(url)
|
||||||
|
const activeFilter = url.searchParams.get('active')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conditions: SQL<unknown>[] = []
|
||||||
|
if (activeFilter === 'true') {
|
||||||
|
conditions.push(eq(referralCampaigns.isActive, true))
|
||||||
|
} else if (activeFilter === 'false') {
|
||||||
|
conditions.push(eq(referralCampaigns.isActive, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? conditions[0] : undefined
|
||||||
|
const baseUrl = getBaseUrl()
|
||||||
|
|
||||||
|
const [countResult, campaigns] = await Promise.all([
|
||||||
|
db.select({ total: count() }).from(referralCampaigns).where(whereClause),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(referralCampaigns)
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(referralCampaigns.createdAt)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset),
|
||||||
|
])
|
||||||
|
|
||||||
|
const total = countResult[0].total
|
||||||
|
const data: AdminReferralCampaign[] = campaigns.map((c) => toAdminReferralCampaign(c, baseUrl))
|
||||||
|
const pagination = createPaginationMeta(total, limit, offset)
|
||||||
|
|
||||||
|
logger.info(`Admin API: Listed ${data.length} referral campaigns (total: ${total})`)
|
||||||
|
|
||||||
|
return listResponse(data, pagination)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin API: Failed to list referral campaigns', { error })
|
||||||
|
return internalErrorResponse('Failed to list referral campaigns')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const POST = withAdminAuth(async (request) => {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { name, code, utmSource, utmMedium, utmCampaign, utmContent, bonusCreditAmount } = body
|
||||||
|
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return badRequestResponse('name is required and must be a string')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof bonusCreditAmount !== 'number' ||
|
||||||
|
!Number.isFinite(bonusCreditAmount) ||
|
||||||
|
bonusCreditAmount <= 0
|
||||||
|
) {
|
||||||
|
return badRequestResponse('bonusCreditAmount must be a positive number')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code !== undefined && code !== null) {
|
||||||
|
if (typeof code !== 'string') {
|
||||||
|
return badRequestResponse('code must be a string or null')
|
||||||
|
}
|
||||||
|
if (code.trim().length < 6) {
|
||||||
|
return badRequestResponse('code must be at least 6 characters')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = nanoid()
|
||||||
|
|
||||||
|
const [campaign] = await db
|
||||||
|
.insert(referralCampaigns)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
code: code ? code.trim().toUpperCase() : null,
|
||||||
|
utmSource: utmSource || null,
|
||||||
|
utmMedium: utmMedium || null,
|
||||||
|
utmCampaign: utmCampaign || null,
|
||||||
|
utmContent: utmContent || null,
|
||||||
|
bonusCreditAmount: bonusCreditAmount.toString(),
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
logger.info(`Admin API: Created referral campaign ${id}`, {
|
||||||
|
name,
|
||||||
|
code: campaign.code,
|
||||||
|
bonusCreditAmount,
|
||||||
|
})
|
||||||
|
|
||||||
|
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin API: Failed to create referral campaign', { error })
|
||||||
|
return internalErrorResponse('Failed to create referral campaign')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import type {
|
import type {
|
||||||
member,
|
member,
|
||||||
organization,
|
organization,
|
||||||
|
referralCampaigns,
|
||||||
subscription,
|
subscription,
|
||||||
user,
|
user,
|
||||||
userStats,
|
userStats,
|
||||||
@@ -31,6 +32,7 @@ export type DbOrganization = InferSelectModel<typeof organization>
|
|||||||
export type DbSubscription = InferSelectModel<typeof subscription>
|
export type DbSubscription = InferSelectModel<typeof subscription>
|
||||||
export type DbMember = InferSelectModel<typeof member>
|
export type DbMember = InferSelectModel<typeof member>
|
||||||
export type DbUserStats = InferSelectModel<typeof userStats>
|
export type DbUserStats = InferSelectModel<typeof userStats>
|
||||||
|
export type DbReferralCampaign = InferSelectModel<typeof referralCampaigns>
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Pagination
|
// Pagination
|
||||||
@@ -646,3 +648,49 @@ export interface AdminDeployResult {
|
|||||||
export interface AdminUndeployResult {
|
export interface AdminUndeployResult {
|
||||||
isDeployed: boolean
|
isDeployed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Referral Campaign Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface AdminReferralCampaign {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
code: string | null
|
||||||
|
utmSource: string | null
|
||||||
|
utmMedium: string | null
|
||||||
|
utmCampaign: string | null
|
||||||
|
utmContent: string | null
|
||||||
|
bonusCreditAmount: string
|
||||||
|
isActive: boolean
|
||||||
|
signupUrl: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAdminReferralCampaign(
|
||||||
|
dbCampaign: DbReferralCampaign,
|
||||||
|
baseUrl: string
|
||||||
|
): AdminReferralCampaign {
|
||||||
|
const utmParams = new URLSearchParams()
|
||||||
|
if (dbCampaign.utmSource) utmParams.set('utm_source', dbCampaign.utmSource)
|
||||||
|
if (dbCampaign.utmMedium) utmParams.set('utm_medium', dbCampaign.utmMedium)
|
||||||
|
if (dbCampaign.utmCampaign) utmParams.set('utm_campaign', dbCampaign.utmCampaign)
|
||||||
|
if (dbCampaign.utmContent) utmParams.set('utm_content', dbCampaign.utmContent)
|
||||||
|
const query = utmParams.toString()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dbCampaign.id,
|
||||||
|
name: dbCampaign.name,
|
||||||
|
code: dbCampaign.code,
|
||||||
|
utmSource: dbCampaign.utmSource,
|
||||||
|
utmMedium: dbCampaign.utmMedium,
|
||||||
|
utmCampaign: dbCampaign.utmCampaign,
|
||||||
|
utmContent: dbCampaign.utmContent,
|
||||||
|
bonusCreditAmount: dbCampaign.bonusCreditAmount,
|
||||||
|
isActive: dbCampaign.isActive,
|
||||||
|
signupUrl: query ? `${baseUrl}/signup?${query}` : null,
|
||||||
|
createdAt: dbCampaign.createdAt.toISOString(),
|
||||||
|
updatedAt: dbCampaign.updatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
|
|||||||
import { authenticateV1Request } from '@/app/api/v1/auth'
|
import { authenticateV1Request } from '@/app/api/v1/auth'
|
||||||
|
|
||||||
const logger = createLogger('CopilotHeadlessAPI')
|
const logger = createLogger('CopilotHeadlessAPI')
|
||||||
const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6'
|
const DEFAULT_COPILOT_MODEL = 'claude-opus-4-5'
|
||||||
|
|
||||||
const RequestSchema = z.object({
|
const RequestSchema = z.object({
|
||||||
message: z.string().min(1, 'message is required'),
|
message: z.string().min(1, 'message is required'),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const patchBodySchema = z
|
|||||||
description: z
|
description: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.max(500, 'Description must be 500 characters or less')
|
.max(2000, 'Description must be 2000 characters or less')
|
||||||
.nullable()
|
.nullable()
|
||||||
.optional(),
|
.optional(),
|
||||||
isActive: z.literal(true).optional(), // Set to true to activate this version
|
isActive: z.literal(true).optional(), // Set to true to activate this version
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer'
|
||||||
import { processInputFileFields } from '@/lib/execution/files'
|
import { processInputFileFields } from '@/lib/execution/files'
|
||||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||||
@@ -700,15 +700,27 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync)
|
const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync)
|
||||||
let isStreamClosed = false
|
let isStreamClosed = false
|
||||||
|
|
||||||
|
const eventWriter = createExecutionEventWriter(executionId)
|
||||||
|
setExecutionMeta(executionId, {
|
||||||
|
status: 'active',
|
||||||
|
userId: actorUserId,
|
||||||
|
workflowId,
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
const sendEvent = (event: ExecutionEvent) => {
|
let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null
|
||||||
if (isStreamClosed) return
|
|
||||||
|
|
||||||
try {
|
const sendEvent = (event: ExecutionEvent) => {
|
||||||
controller.enqueue(encodeSSEEvent(event))
|
if (!isStreamClosed) {
|
||||||
} catch {
|
try {
|
||||||
isStreamClosed = true
|
controller.enqueue(encodeSSEEvent(event))
|
||||||
|
} catch {
|
||||||
|
isStreamClosed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.type !== 'stream:chunk' && event.type !== 'stream:done') {
|
||||||
|
eventWriter.write(event).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,14 +841,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
const reader = streamingExec.stream.getReader()
|
const reader = streamingExec.stream.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
let chunkCount = 0
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) break
|
if (done) break
|
||||||
|
|
||||||
chunkCount++
|
|
||||||
const chunk = decoder.decode(value, { stream: true })
|
const chunk = decoder.decode(value, { stream: true })
|
||||||
sendEvent({
|
sendEvent({
|
||||||
type: 'stream:chunk',
|
type: 'stream:chunk',
|
||||||
@@ -951,6 +961,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
duration: result.metadata?.duration || 0,
|
duration: result.metadata?.duration || 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
finalMetaStatus = 'error'
|
||||||
} else {
|
} else {
|
||||||
logger.info(`[${requestId}] Workflow execution was cancelled`)
|
logger.info(`[${requestId}] Workflow execution was cancelled`)
|
||||||
|
|
||||||
@@ -963,6 +974,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
duration: result.metadata?.duration || 0,
|
duration: result.metadata?.duration || 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
finalMetaStatus = 'cancelled'
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -986,6 +998,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
endTime: result.metadata?.endTime || new Date().toISOString(),
|
endTime: result.metadata?.endTime || new Date().toISOString(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
finalMetaStatus = 'complete'
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const isTimeout = isTimeoutError(error) || timeoutController.isTimedOut()
|
const isTimeout = isTimeoutError(error) || timeoutController.isTimedOut()
|
||||||
const errorMessage = isTimeout
|
const errorMessage = isTimeout
|
||||||
@@ -1017,7 +1030,18 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
duration: executionResult?.metadata?.duration || 0,
|
duration: executionResult?.metadata?.duration || 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
finalMetaStatus = 'error'
|
||||||
} finally {
|
} finally {
|
||||||
|
try {
|
||||||
|
await eventWriter.close()
|
||||||
|
} catch (closeError) {
|
||||||
|
logger.warn(`[${requestId}] Failed to close event writer`, {
|
||||||
|
error: closeError instanceof Error ? closeError.message : String(closeError),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (finalMetaStatus) {
|
||||||
|
setExecutionMeta(executionId, { status: finalMetaStatus }).catch(() => {})
|
||||||
|
}
|
||||||
timeoutController.cleanup()
|
timeoutController.cleanup()
|
||||||
if (executionId) {
|
if (executionId) {
|
||||||
await cleanupExecutionBase64Cache(executionId)
|
await cleanupExecutionBase64Cache(executionId)
|
||||||
@@ -1032,10 +1056,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
isStreamClosed = true
|
isStreamClosed = true
|
||||||
timeoutController.cleanup()
|
logger.info(`[${requestId}] Client disconnected from SSE stream`)
|
||||||
logger.info(`[${requestId}] Client aborted SSE stream, signalling cancellation`)
|
|
||||||
timeoutController.abort()
|
|
||||||
markExecutionCancelled(executionId).catch(() => {})
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||||
|
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||||
|
import {
|
||||||
|
type ExecutionStreamStatus,
|
||||||
|
getExecutionMeta,
|
||||||
|
readExecutionEvents,
|
||||||
|
} from '@/lib/execution/event-buffer'
|
||||||
|
import { formatSSEEvent } from '@/lib/workflows/executor/execution-events'
|
||||||
|
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||||
|
|
||||||
|
const logger = createLogger('ExecutionStreamReconnectAPI')
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 500
|
||||||
|
const MAX_POLL_DURATION_MS = 10 * 60 * 1000 // 10 minutes
|
||||||
|
|
||||||
|
function isTerminalStatus(status: ExecutionStreamStatus): boolean {
|
||||||
|
return status === 'complete' || status === 'error' || status === 'cancelled'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; executionId: string }> }
|
||||||
|
) {
|
||||||
|
const { id: workflowId, executionId } = await params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
|
||||||
|
if (!auth.success || !auth.userId) {
|
||||||
|
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
|
||||||
|
workflowId,
|
||||||
|
userId: auth.userId,
|
||||||
|
action: 'read',
|
||||||
|
})
|
||||||
|
if (!workflowAuthorization.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: workflowAuthorization.message || 'Access denied' },
|
||||||
|
{ status: workflowAuthorization.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await getExecutionMeta(executionId)
|
||||||
|
if (!meta) {
|
||||||
|
return NextResponse.json({ error: 'Execution buffer not found or expired' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.workflowId && meta.workflowId !== workflowId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Execution does not belong to this workflow' },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromParam = req.nextUrl.searchParams.get('from')
|
||||||
|
const parsed = fromParam ? Number.parseInt(fromParam, 10) : 0
|
||||||
|
const fromEventId = Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
|
||||||
|
|
||||||
|
logger.info('Reconnection stream requested', {
|
||||||
|
workflowId,
|
||||||
|
executionId,
|
||||||
|
fromEventId,
|
||||||
|
metaStatus: meta.status,
|
||||||
|
})
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
|
let closed = false
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
async start(controller) {
|
||||||
|
let lastEventId = fromEventId
|
||||||
|
const pollDeadline = Date.now() + MAX_POLL_DURATION_MS
|
||||||
|
|
||||||
|
const enqueue = (text: string) => {
|
||||||
|
if (closed) return
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(text))
|
||||||
|
} catch {
|
||||||
|
closed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await readExecutionEvents(executionId, lastEventId)
|
||||||
|
for (const entry of events) {
|
||||||
|
if (closed) return
|
||||||
|
enqueue(formatSSEEvent(entry.event))
|
||||||
|
lastEventId = entry.eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMeta = await getExecutionMeta(executionId)
|
||||||
|
if (!currentMeta || isTerminalStatus(currentMeta.status)) {
|
||||||
|
enqueue('data: [DONE]\n\n')
|
||||||
|
if (!closed) controller.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!closed && Date.now() < pollDeadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||||
|
if (closed) return
|
||||||
|
|
||||||
|
const newEvents = await readExecutionEvents(executionId, lastEventId)
|
||||||
|
for (const entry of newEvents) {
|
||||||
|
if (closed) return
|
||||||
|
enqueue(formatSSEEvent(entry.event))
|
||||||
|
lastEventId = entry.eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
const polledMeta = await getExecutionMeta(executionId)
|
||||||
|
if (!polledMeta || isTerminalStatus(polledMeta.status)) {
|
||||||
|
const finalEvents = await readExecutionEvents(executionId, lastEventId)
|
||||||
|
for (const entry of finalEvents) {
|
||||||
|
if (closed) return
|
||||||
|
enqueue(formatSSEEvent(entry.event))
|
||||||
|
lastEventId = entry.eventId
|
||||||
|
}
|
||||||
|
enqueue('data: [DONE]\n\n')
|
||||||
|
if (!closed) controller.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!closed) {
|
||||||
|
logger.warn('Reconnection stream poll deadline reached', { executionId })
|
||||||
|
enqueue('data: [DONE]\n\n')
|
||||||
|
controller.close()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in reconnection stream', {
|
||||||
|
executionId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
if (!closed) {
|
||||||
|
try {
|
||||||
|
controller.close()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
closed = true
|
||||||
|
logger.info('Client disconnected from reconnection stream', { executionId })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new NextResponse(stream, {
|
||||||
|
headers: {
|
||||||
|
...SSE_HEADERS,
|
||||||
|
'X-Execution-Id': executionId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Failed to start reconnection stream', {
|
||||||
|
workflowId,
|
||||||
|
executionId,
|
||||||
|
error: error.message,
|
||||||
|
})
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Failed to start reconnection stream' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { loggerMock } from '@sim/testing'
|
import { loggerMock, setupGlobalFetchMock } from '@sim/testing'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
@@ -284,9 +284,7 @@ describe('Workflow By ID API Route', () => {
|
|||||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||||
})
|
})
|
||||||
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
setupGlobalFetchMock({ ok: true })
|
||||||
ok: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -331,9 +329,7 @@ describe('Workflow By ID API Route', () => {
|
|||||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||||
})
|
})
|
||||||
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
setupGlobalFetchMock({ ok: true })
|
||||||
ok: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function VersionDescriptionModal({
|
|||||||
className='min-h-[120px] resize-none'
|
className='min-h-[120px] resize-none'
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
maxLength={500}
|
maxLength={2000}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
/>
|
/>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
@@ -123,7 +123,7 @@ export function VersionDescriptionModal({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!updateMutation.error && !generateMutation.error && <div />}
|
{!updateMutation.error && !generateMutation.error && <div />}
|
||||||
<p className='text-[11px] text-[var(--text-tertiary)]'>{description.length}/500</p>
|
<p className='text-[11px] text-[var(--text-tertiary)]'>{description.length}/2000</p>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks'
|
||||||
import { useNotificationStore } from '@/stores/notifications'
|
import { useNotificationStore } from '@/stores/notifications'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||||
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
|
|
||||||
const logger = createLogger('useDeployment')
|
const logger = createLogger('useDeployment')
|
||||||
|
|
||||||
@@ -35,6 +38,24 @@ export function useDeployment({
|
|||||||
return { success: true, shouldOpenModal: true }
|
return { success: true, shouldOpenModal: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { blocks, edges, loops, parallels } = useWorkflowStore.getState()
|
||||||
|
const liveBlocks = mergeSubblockState(blocks, workflowId)
|
||||||
|
const checkResult = runPreDeployChecks({
|
||||||
|
blocks: liveBlocks,
|
||||||
|
edges,
|
||||||
|
loops,
|
||||||
|
parallels,
|
||||||
|
workflowId,
|
||||||
|
})
|
||||||
|
if (!checkResult.passed) {
|
||||||
|
addNotification({
|
||||||
|
level: 'error',
|
||||||
|
message: checkResult.error || 'Pre-deploy validation failed',
|
||||||
|
workflowId,
|
||||||
|
})
|
||||||
|
return { success: false, shouldOpenModal: false }
|
||||||
|
}
|
||||||
|
|
||||||
setIsDeploying(true)
|
setIsDeploying(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||||
|
|||||||
@@ -139,46 +139,6 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
|||||||
'delete:issue-worklog:jira': 'Delete worklog entries from Jira issues',
|
'delete:issue-worklog:jira': 'Delete worklog entries from Jira issues',
|
||||||
'write:issue-link:jira': 'Create links between Jira issues',
|
'write:issue-link:jira': 'Create links between Jira issues',
|
||||||
'delete:issue-link:jira': 'Delete links between Jira issues',
|
'delete:issue-link:jira': 'Delete links between Jira issues',
|
||||||
'manage:jira-project': 'Manage Jira project components and versions',
|
|
||||||
'read:board-scope:jira-software': 'View Jira boards',
|
|
||||||
'write:board-scope:jira-software': 'Manage Jira boards and backlog',
|
|
||||||
'read:sprint:jira-software': 'View Jira sprints',
|
|
||||||
'write:sprint:jira-software': 'Create and manage Jira sprints',
|
|
||||||
'delete:sprint:jira-software': 'Delete Jira sprints',
|
|
||||||
'read:servicedesk:jira-service-management': 'View JSM service desks',
|
|
||||||
'read:requesttype:jira-service-management': 'View JSM request types',
|
|
||||||
'read:request:jira-service-management': 'View JSM service requests',
|
|
||||||
'write:request:jira-service-management': 'Create and update JSM service requests',
|
|
||||||
'read:request.comment:jira-service-management': 'View comments on JSM requests',
|
|
||||||
'write:request.comment:jira-service-management': 'Add comments to JSM requests',
|
|
||||||
'read:customer:jira-service-management': 'View JSM customers',
|
|
||||||
'write:customer:jira-service-management': 'Create and manage JSM customers',
|
|
||||||
'read:servicedesk.customer:jira-service-management': 'View service desk customers',
|
|
||||||
'write:servicedesk.customer:jira-service-management': 'Add customers to service desks',
|
|
||||||
'delete:servicedesk.customer:jira-service-management': 'Remove customers from service desks',
|
|
||||||
'read:organization:jira-service-management': 'View JSM organizations',
|
|
||||||
'write:organization:jira-service-management': 'Create and manage JSM organizations',
|
|
||||||
'delete:organization:jira-service-management': 'Delete JSM organizations',
|
|
||||||
'read:servicedesk.organization:jira-service-management': 'View service desk organizations',
|
|
||||||
'write:servicedesk.organization:jira-service-management': 'Add organizations to service desks',
|
|
||||||
'read:organization.user:jira-service-management': 'View organization users',
|
|
||||||
'write:organization.user:jira-service-management': 'Add users to organizations',
|
|
||||||
'read:queue:jira-service-management': 'View JSM queues and queue issues',
|
|
||||||
'read:request.sla:jira-service-management': 'View request SLA information',
|
|
||||||
'read:request.status:jira-service-management': 'View request status history',
|
|
||||||
'write:request.status:jira-service-management': 'Transition request status',
|
|
||||||
'read:request.participant:jira-service-management': 'View request participants',
|
|
||||||
'write:request.participant:jira-service-management': 'Add request participants',
|
|
||||||
'read:request.approval:jira-service-management': 'View request approvals',
|
|
||||||
'write:request.approval:jira-service-management': 'Respond to request approvals',
|
|
||||||
'read:request.feedback:jira-service-management': 'View request feedback',
|
|
||||||
'write:request.feedback:jira-service-management': 'Add request feedback',
|
|
||||||
'delete:request.feedback:jira-service-management': 'Delete request feedback',
|
|
||||||
'read:request.notification:jira-service-management': 'View request notification status',
|
|
||||||
'write:request.notification:jira-service-management': 'Subscribe to request notifications',
|
|
||||||
'delete:request.notification:jira-service-management': 'Unsubscribe from request notifications',
|
|
||||||
'read:request.attachment:jira-service-management': 'View request attachments',
|
|
||||||
'read:knowledgebase:jira-service-management': 'Search knowledge base articles',
|
|
||||||
'User.Read': 'Read Microsoft user',
|
'User.Read': 'Read Microsoft user',
|
||||||
'Chat.Read': 'Read Microsoft chats',
|
'Chat.Read': 'Read Microsoft chats',
|
||||||
'Chat.ReadWrite': 'Write to Microsoft chats',
|
'Chat.ReadWrite': 'Write to Microsoft chats',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button, Combobox } from '@/components/emcn/components'
|
|||||||
import {
|
import {
|
||||||
getCanonicalScopesForProvider,
|
getCanonicalScopesForProvider,
|
||||||
getProviderIdFromServiceId,
|
getProviderIdFromServiceId,
|
||||||
|
getServiceConfigByProviderId,
|
||||||
OAUTH_PROVIDERS,
|
OAUTH_PROVIDERS,
|
||||||
type OAuthProvider,
|
type OAuthProvider,
|
||||||
type OAuthService,
|
type OAuthService,
|
||||||
@@ -26,6 +27,11 @@ const getProviderIcon = (providerName: OAuthProvider) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getProviderName = (providerName: OAuthProvider) => {
|
const getProviderName = (providerName: OAuthProvider) => {
|
||||||
|
const serviceConfig = getServiceConfigByProviderId(providerName)
|
||||||
|
if (serviceConfig) {
|
||||||
|
return serviceConfig.name
|
||||||
|
}
|
||||||
|
|
||||||
const { baseProvider } = parseProvider(providerName)
|
const { baseProvider } = parseProvider(providerName)
|
||||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||||
|
|
||||||
@@ -54,7 +60,7 @@ export function ToolCredentialSelector({
|
|||||||
onChange,
|
onChange,
|
||||||
provider,
|
provider,
|
||||||
requiredScopes = [],
|
requiredScopes = [],
|
||||||
label = 'Select account',
|
label,
|
||||||
serviceId,
|
serviceId,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: ToolCredentialSelectorProps) {
|
}: ToolCredentialSelectorProps) {
|
||||||
@@ -64,6 +70,7 @@ export function ToolCredentialSelector({
|
|||||||
const { activeWorkflowId } = useWorkflowRegistry()
|
const { activeWorkflowId } = useWorkflowRegistry()
|
||||||
|
|
||||||
const selectedId = value || ''
|
const selectedId = value || ''
|
||||||
|
const effectiveLabel = label || `Select ${getProviderName(provider)} account`
|
||||||
|
|
||||||
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
||||||
|
|
||||||
@@ -203,7 +210,7 @@ export function ToolCredentialSelector({
|
|||||||
selectedValue={selectedId}
|
selectedValue={selectedId}
|
||||||
onChange={handleComboboxChange}
|
onChange={handleComboboxChange}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
placeholder={label}
|
placeholder={effectiveLabel}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
editable={true}
|
editable={true}
|
||||||
filterOptions={!isForeign}
|
filterOptions={!isForeign}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type React from 'react'
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { ArrowLeftRight, ArrowUp } from 'lucide-react'
|
||||||
|
import { Button, Input, Label, Tooltip } from '@/components/emcn'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for a generic parameter with label component
|
||||||
|
*/
|
||||||
|
export interface ParameterWithLabelProps {
|
||||||
|
paramId: string
|
||||||
|
title: string
|
||||||
|
isRequired: boolean
|
||||||
|
visibility: string
|
||||||
|
wandConfig?: {
|
||||||
|
enabled: boolean
|
||||||
|
prompt?: string
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
canonicalToggle?: {
|
||||||
|
mode: 'basic' | 'advanced'
|
||||||
|
disabled?: boolean
|
||||||
|
onToggle?: () => void
|
||||||
|
}
|
||||||
|
disabled: boolean
|
||||||
|
isPreview: boolean
|
||||||
|
children: (wandControlRef: React.MutableRefObject<WandControlHandlers | null>) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic wrapper component for parameters that manages wand state and renders label + input
|
||||||
|
*/
|
||||||
|
export function ParameterWithLabel({
|
||||||
|
paramId,
|
||||||
|
title,
|
||||||
|
isRequired,
|
||||||
|
visibility,
|
||||||
|
wandConfig,
|
||||||
|
canonicalToggle,
|
||||||
|
disabled,
|
||||||
|
isPreview,
|
||||||
|
children,
|
||||||
|
}: ParameterWithLabelProps) {
|
||||||
|
const [isSearchActive, setIsSearchActive] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const wandControlRef = useRef<WandControlHandlers | null>(null)
|
||||||
|
|
||||||
|
const isWandEnabled = wandConfig?.enabled ?? false
|
||||||
|
const showWand = isWandEnabled && !isPreview && !disabled
|
||||||
|
|
||||||
|
const handleSearchClick = (): void => {
|
||||||
|
setIsSearchActive(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
searchInputRef.current?.focus()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchBlur = (): void => {
|
||||||
|
if (!searchQuery.trim() && !wandControlRef.current?.isWandStreaming) {
|
||||||
|
setIsSearchActive(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string): void => {
|
||||||
|
setSearchQuery(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchSubmit = (): void => {
|
||||||
|
if (searchQuery.trim() && wandControlRef.current) {
|
||||||
|
wandControlRef.current.onWandTrigger(searchQuery)
|
||||||
|
setSearchQuery('')
|
||||||
|
setIsSearchActive(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchCancel = (): void => {
|
||||||
|
setSearchQuery('')
|
||||||
|
setIsSearchActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStreaming = wandControlRef.current?.isWandStreaming ?? false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={paramId} className='relative min-w-0 space-y-[6px]'>
|
||||||
|
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
||||||
|
<Label className='flex items-baseline gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
|
{title}
|
||||||
|
{isRequired && visibility === 'user-only' && <span className='ml-0.5'>*</span>}
|
||||||
|
</Label>
|
||||||
|
<div className='flex min-w-0 flex-1 items-center justify-end gap-[6px]'>
|
||||||
|
{showWand &&
|
||||||
|
(!isSearchActive ? (
|
||||||
|
<Button
|
||||||
|
variant='active'
|
||||||
|
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||||
|
onClick={handleSearchClick}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className='-my-1 flex min-w-[120px] max-w-[280px] flex-1 items-center gap-[4px]'>
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
value={isStreaming ? 'Generating...' : searchQuery}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
handleSearchChange(e.target.value)
|
||||||
|
}
|
||||||
|
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||||
|
if (relatedTarget?.closest('button')) return
|
||||||
|
handleSearchBlur()
|
||||||
|
}}
|
||||||
|
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && searchQuery.trim() && !isStreaming) {
|
||||||
|
handleSearchSubmit()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
handleSearchCancel()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isStreaming}
|
||||||
|
className={cn(
|
||||||
|
'h-5 min-w-[80px] flex-1 text-[11px]',
|
||||||
|
isStreaming && 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
placeholder='Generate with AI...'
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant='tertiary'
|
||||||
|
disabled={!searchQuery.trim() || isStreaming}
|
||||||
|
onMouseDown={(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleSearchSubmit()
|
||||||
|
}}
|
||||||
|
className='h-[20px] w-[20px] flex-shrink-0 p-0'
|
||||||
|
>
|
||||||
|
<ArrowUp className='h-[12px] w-[12px]' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{canonicalToggle && !isPreview && (
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
onClick={canonicalToggle.onToggle}
|
||||||
|
disabled={canonicalToggle.disabled || disabled}
|
||||||
|
aria-label={
|
||||||
|
canonicalToggle.mode === 'advanced'
|
||||||
|
? 'Switch to selector'
|
||||||
|
: 'Switch to manual ID'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowLeftRight
|
||||||
|
className={cn(
|
||||||
|
'!h-[12px] !w-[12px]',
|
||||||
|
canonicalToggle.mode === 'advanced'
|
||||||
|
? 'text-[var(--text-primary)]'
|
||||||
|
: 'text-[var(--text-secondary)]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content side='top'>
|
||||||
|
<p>
|
||||||
|
{canonicalToggle.mode === 'advanced'
|
||||||
|
? 'Switch to selector'
|
||||||
|
: 'Switch to manual ID'}
|
||||||
|
</p>
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='relative w-full min-w-0'>{children(wandControlRef)}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||||
|
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||||
|
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||||
|
|
||||||
|
interface ToolSubBlockRendererProps {
|
||||||
|
blockId: string
|
||||||
|
subBlockId: string
|
||||||
|
toolIndex: number
|
||||||
|
subBlock: BlockSubBlockConfig
|
||||||
|
effectiveParamId: string
|
||||||
|
toolParams: Record<string, string> | undefined
|
||||||
|
onParamChange: (toolIndex: number, paramId: string, value: string) => void
|
||||||
|
disabled: boolean
|
||||||
|
canonicalToggle?: {
|
||||||
|
mode: 'basic' | 'advanced'
|
||||||
|
disabled?: boolean
|
||||||
|
onToggle?: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SubBlock types whose store values are objects/arrays/non-strings.
|
||||||
|
* tool.params stores strings (via JSON.stringify), so when syncing
|
||||||
|
* back to the store we parse them to restore the native shape.
|
||||||
|
*/
|
||||||
|
const OBJECT_SUBBLOCK_TYPES = new Set(['file-upload', 'table', 'grouped-checkbox-list'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges the subblock store with StoredTool.params via a synthetic store key,
|
||||||
|
* then delegates all rendering to SubBlock for full parity.
|
||||||
|
*/
|
||||||
|
export function ToolSubBlockRenderer({
|
||||||
|
blockId,
|
||||||
|
subBlockId,
|
||||||
|
toolIndex,
|
||||||
|
subBlock,
|
||||||
|
effectiveParamId,
|
||||||
|
toolParams,
|
||||||
|
onParamChange,
|
||||||
|
disabled,
|
||||||
|
canonicalToggle,
|
||||||
|
}: ToolSubBlockRendererProps) {
|
||||||
|
const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}`
|
||||||
|
const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId)
|
||||||
|
|
||||||
|
const toolParamValue = toolParams?.[effectiveParamId] ?? ''
|
||||||
|
const isObjectType = OBJECT_SUBBLOCK_TYPES.has(subBlock.type)
|
||||||
|
|
||||||
|
const lastPushedToStoreRef = useRef<string | null>(null)
|
||||||
|
const lastPushedToParamsRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toolParamValue && lastPushedToStoreRef.current === null) {
|
||||||
|
lastPushedToStoreRef.current = toolParamValue
|
||||||
|
lastPushedToParamsRef.current = toolParamValue
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (toolParamValue !== lastPushedToStoreRef.current) {
|
||||||
|
lastPushedToStoreRef.current = toolParamValue
|
||||||
|
lastPushedToParamsRef.current = toolParamValue
|
||||||
|
|
||||||
|
if (isObjectType && typeof toolParamValue === 'string' && toolParamValue) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(toolParamValue)
|
||||||
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
|
setStoreValue(parsed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON — fall through to set as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStoreValue(toolParamValue)
|
||||||
|
}
|
||||||
|
}, [toolParamValue, setStoreValue, isObjectType])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (storeValue == null && lastPushedToParamsRef.current === null) return
|
||||||
|
const stringValue =
|
||||||
|
storeValue == null
|
||||||
|
? ''
|
||||||
|
: typeof storeValue === 'string'
|
||||||
|
? storeValue
|
||||||
|
: JSON.stringify(storeValue)
|
||||||
|
if (stringValue !== lastPushedToParamsRef.current) {
|
||||||
|
lastPushedToParamsRef.current = stringValue
|
||||||
|
lastPushedToStoreRef.current = stringValue
|
||||||
|
onParamChange(toolIndex, effectiveParamId, stringValue)
|
||||||
|
}
|
||||||
|
}, [storeValue, toolIndex, effectiveParamId, onParamChange])
|
||||||
|
|
||||||
|
const visibility = subBlock.paramVisibility ?? 'user-or-llm'
|
||||||
|
const isOptionalForUser = visibility !== 'user-only'
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
...subBlock,
|
||||||
|
id: syntheticId,
|
||||||
|
...(isOptionalForUser && { required: false }),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubBlock
|
||||||
|
blockId={blockId}
|
||||||
|
config={config}
|
||||||
|
isPreview={false}
|
||||||
|
disabled={disabled}
|
||||||
|
canonicalToggle={canonicalToggle}
|
||||||
|
dependencyContext={toolParams}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,37 +2,12 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
|
||||||
interface StoredTool {
|
import {
|
||||||
type: string
|
isCustomToolAlreadySelected,
|
||||||
title?: string
|
isMcpToolAlreadySelected,
|
||||||
toolId?: string
|
isWorkflowAlreadySelected,
|
||||||
params?: Record<string, string>
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils'
|
||||||
customToolId?: string
|
|
||||||
schema?: any
|
|
||||||
code?: string
|
|
||||||
operation?: string
|
|
||||||
usageControl?: 'auto' | 'force' | 'none'
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMcpToolAlreadySelected = (selectedTools: StoredTool[], mcpToolId: string): boolean => {
|
|
||||||
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCustomToolAlreadySelected = (
|
|
||||||
selectedTools: StoredTool[],
|
|
||||||
customToolId: string
|
|
||||||
): boolean => {
|
|
||||||
return selectedTools.some(
|
|
||||||
(tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isWorkflowAlreadySelected = (selectedTools: StoredTool[], workflowId: string): boolean => {
|
|
||||||
return selectedTools.some(
|
|
||||||
(tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('isMcpToolAlreadySelected', () => {
|
describe('isMcpToolAlreadySelected', () => {
|
||||||
describe('basic functionality', () => {
|
describe('basic functionality', () => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Represents a tool selected and configured in the workflow
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded.
|
||||||
|
* Everything else (title, schema, code) is loaded dynamically from the database.
|
||||||
|
* Legacy custom tools with inline schema/code are still supported for backwards compatibility.
|
||||||
|
*/
|
||||||
|
export interface StoredTool {
|
||||||
|
/** Block type identifier */
|
||||||
|
type: string
|
||||||
|
/** Display title for the tool (optional for new custom tool format) */
|
||||||
|
title?: string
|
||||||
|
/** Direct tool ID for execution (optional for new custom tool format) */
|
||||||
|
toolId?: string
|
||||||
|
/** Parameter values configured by the user (optional for new custom tool format) */
|
||||||
|
params?: Record<string, string>
|
||||||
|
/** Whether the tool details are expanded in UI */
|
||||||
|
isExpanded?: boolean
|
||||||
|
/** Database ID for custom tools (new format - reference only) */
|
||||||
|
customToolId?: string
|
||||||
|
/** Tool schema for custom tools (legacy format - inline JSON schema) */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
schema?: Record<string, any>
|
||||||
|
/** Implementation code for custom tools (legacy format - inline) */
|
||||||
|
code?: string
|
||||||
|
/** Selected operation for multi-operation tools */
|
||||||
|
operation?: string
|
||||||
|
/** Tool usage control mode for LLM */
|
||||||
|
usageControl?: 'auto' | 'force' | 'none'
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an MCP tool is already selected.
|
||||||
|
*/
|
||||||
|
export function isMcpToolAlreadySelected(selectedTools: StoredTool[], mcpToolId: string): boolean {
|
||||||
|
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a custom tool is already selected.
|
||||||
|
*/
|
||||||
|
export function isCustomToolAlreadySelected(
|
||||||
|
selectedTools: StoredTool[],
|
||||||
|
customToolId: string
|
||||||
|
): boolean {
|
||||||
|
return selectedTools.some(
|
||||||
|
(tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a workflow is already selected.
|
||||||
|
*/
|
||||||
|
export function isWorkflowAlreadySelected(
|
||||||
|
selectedTools: StoredTool[],
|
||||||
|
workflowId: string
|
||||||
|
): boolean {
|
||||||
|
return selectedTools.some(
|
||||||
|
(tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import { isEqual } from 'lodash'
|
|||||||
import { AlertTriangle, ArrowLeftRight, ArrowUp, Check, Clipboard } from 'lucide-react'
|
import { AlertTriangle, ArrowLeftRight, ArrowUp, Check, Clipboard } from 'lucide-react'
|
||||||
import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
|
import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
|
|
||||||
import {
|
import {
|
||||||
CheckboxList,
|
CheckboxList,
|
||||||
Code,
|
Code,
|
||||||
@@ -69,13 +68,15 @@ interface SubBlockProps {
|
|||||||
isPreview?: boolean
|
isPreview?: boolean
|
||||||
subBlockValues?: Record<string, any>
|
subBlockValues?: Record<string, any>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
fieldDiffStatus?: FieldDiffStatus
|
|
||||||
allowExpandInPreview?: boolean
|
allowExpandInPreview?: boolean
|
||||||
canonicalToggle?: {
|
canonicalToggle?: {
|
||||||
mode: 'basic' | 'advanced'
|
mode: 'basic' | 'advanced'
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onToggle?: () => void
|
onToggle?: () => void
|
||||||
}
|
}
|
||||||
|
labelSuffix?: React.ReactNode
|
||||||
|
/** Provides sibling values for dependency resolution in non-preview contexts (e.g. tool-input) */
|
||||||
|
dependencyContext?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,16 +163,14 @@ const getPreviewValue = (
|
|||||||
/**
|
/**
|
||||||
* Renders the label with optional validation and description tooltips.
|
* Renders the label with optional validation and description tooltips.
|
||||||
*
|
*
|
||||||
* @remarks
|
|
||||||
* Handles JSON validation indicators for code blocks and required field markers.
|
|
||||||
* Includes inline AI generate button when wand is enabled.
|
|
||||||
*
|
|
||||||
* @param config - The sub-block configuration defining the label content
|
* @param config - The sub-block configuration defining the label content
|
||||||
* @param isValidJson - Whether the JSON content is valid (for code blocks)
|
* @param isValidJson - Whether the JSON content is valid (for code blocks)
|
||||||
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
|
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
|
||||||
* @param wandState - Optional state and handlers for the AI wand feature
|
* @param wandState - State and handlers for the inline AI generate feature
|
||||||
* @param canonicalToggle - Optional canonical toggle metadata and handlers
|
* @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
|
||||||
* @param canonicalToggleIsDisabled - Whether the canonical toggle is disabled
|
* @param canonicalToggleIsDisabled - Whether the canonical toggle is disabled (includes dependsOn gating)
|
||||||
|
* @param copyState - State and handler for the copy-to-clipboard button
|
||||||
|
* @param labelSuffix - Additional content rendered after the label text
|
||||||
* @returns The label JSX element, or `null` for switch types or when no title is defined
|
* @returns The label JSX element, or `null` for switch types or when no title is defined
|
||||||
*/
|
*/
|
||||||
const renderLabel = (
|
const renderLabel = (
|
||||||
@@ -202,7 +201,8 @@ const renderLabel = (
|
|||||||
showCopyButton: boolean
|
showCopyButton: boolean
|
||||||
copied: boolean
|
copied: boolean
|
||||||
onCopy: () => void
|
onCopy: () => void
|
||||||
}
|
},
|
||||||
|
labelSuffix?: React.ReactNode
|
||||||
): JSX.Element | null => {
|
): JSX.Element | null => {
|
||||||
if (config.type === 'switch') return null
|
if (config.type === 'switch') return null
|
||||||
if (!config.title) return null
|
if (!config.title) return null
|
||||||
@@ -215,9 +215,10 @@ const renderLabel = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
||||||
<Label className='flex items-center gap-[6px] whitespace-nowrap'>
|
<Label className='flex items-baseline gap-[6px] whitespace-nowrap'>
|
||||||
{config.title}
|
{config.title}
|
||||||
{required && <span className='ml-0.5'>*</span>}
|
{required && <span className='ml-0.5'>*</span>}
|
||||||
|
{labelSuffix}
|
||||||
{config.type === 'code' &&
|
{config.type === 'code' &&
|
||||||
config.language === 'json' &&
|
config.language === 'json' &&
|
||||||
!isValidJson &&
|
!isValidJson &&
|
||||||
@@ -383,28 +384,25 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
|
|||||||
prevProps.isPreview === nextProps.isPreview &&
|
prevProps.isPreview === nextProps.isPreview &&
|
||||||
valueEqual &&
|
valueEqual &&
|
||||||
prevProps.disabled === nextProps.disabled &&
|
prevProps.disabled === nextProps.disabled &&
|
||||||
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
|
|
||||||
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
|
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
|
||||||
canonicalToggleEqual
|
canonicalToggleEqual &&
|
||||||
|
prevProps.labelSuffix === nextProps.labelSuffix &&
|
||||||
|
prevProps.dependencyContext === nextProps.dependencyContext
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a single workflow sub-block input based on config.type.
|
* Renders a single workflow sub-block input based on config.type.
|
||||||
*
|
*
|
||||||
* @remarks
|
|
||||||
* Supports multiple input types including short-input, long-input, dropdown,
|
|
||||||
* combobox, slider, table, code, switch, tool-input, and many more.
|
|
||||||
* Handles preview mode, disabled states, and AI wand generation.
|
|
||||||
*
|
|
||||||
* @param blockId - The parent block identifier
|
* @param blockId - The parent block identifier
|
||||||
* @param config - Configuration defining the input type and properties
|
* @param config - Configuration defining the input type and properties
|
||||||
* @param isPreview - Whether to render in preview mode
|
* @param isPreview - Whether to render in preview mode
|
||||||
* @param subBlockValues - Current values of all subblocks
|
* @param subBlockValues - Current values of all subblocks
|
||||||
* @param disabled - Whether the input is disabled
|
* @param disabled - Whether the input is disabled
|
||||||
* @param fieldDiffStatus - Optional diff status for visual indicators
|
|
||||||
* @param allowExpandInPreview - Whether to allow expanding in preview mode
|
* @param allowExpandInPreview - Whether to allow expanding in preview mode
|
||||||
* @returns The rendered sub-block input component
|
* @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
|
||||||
|
* @param labelSuffix - Additional content rendered after the label text
|
||||||
|
* @param dependencyContext - Sibling values for dependency resolution in non-preview contexts (e.g. tool-input)
|
||||||
*/
|
*/
|
||||||
function SubBlockComponent({
|
function SubBlockComponent({
|
||||||
blockId,
|
blockId,
|
||||||
@@ -412,9 +410,10 @@ function SubBlockComponent({
|
|||||||
isPreview = false,
|
isPreview = false,
|
||||||
subBlockValues,
|
subBlockValues,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
fieldDiffStatus,
|
|
||||||
allowExpandInPreview,
|
allowExpandInPreview,
|
||||||
canonicalToggle,
|
canonicalToggle,
|
||||||
|
labelSuffix,
|
||||||
|
dependencyContext,
|
||||||
}: SubBlockProps): JSX.Element {
|
}: SubBlockProps): JSX.Element {
|
||||||
const [isValidJson, setIsValidJson] = useState(true)
|
const [isValidJson, setIsValidJson] = useState(true)
|
||||||
const [isSearchActive, setIsSearchActive] = useState(false)
|
const [isSearchActive, setIsSearchActive] = useState(false)
|
||||||
@@ -423,7 +422,6 @@ function SubBlockComponent({
|
|||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
const wandControlRef = useRef<WandControlHandlers | null>(null)
|
const wandControlRef = useRef<WandControlHandlers | null>(null)
|
||||||
|
|
||||||
// Use webhook management hook when config has useWebhookUrl enabled
|
|
||||||
const webhookManagement = useWebhookManagement({
|
const webhookManagement = useWebhookManagement({
|
||||||
blockId,
|
blockId,
|
||||||
triggerId: undefined,
|
triggerId: undefined,
|
||||||
@@ -510,10 +508,12 @@ function SubBlockComponent({
|
|||||||
| null
|
| null
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
|
const contextValues = dependencyContext ?? (isPreview ? subBlockValues : undefined)
|
||||||
|
|
||||||
const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, {
|
const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, {
|
||||||
disabled,
|
disabled,
|
||||||
isPreview,
|
isPreview,
|
||||||
previewContextValues: isPreview ? subBlockValues : undefined,
|
previewContextValues: contextValues,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isDisabled = gatedDisabled
|
const isDisabled = gatedDisabled
|
||||||
@@ -797,7 +797,7 @@ function SubBlockComponent({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue}
|
previewValue={previewValue}
|
||||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
previewContextValues={contextValues}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -809,7 +809,7 @@ function SubBlockComponent({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue}
|
previewValue={previewValue}
|
||||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
previewContextValues={contextValues}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -821,7 +821,7 @@ function SubBlockComponent({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue}
|
previewValue={previewValue}
|
||||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
previewContextValues={contextValues}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -833,7 +833,7 @@ function SubBlockComponent({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue}
|
previewValue={previewValue}
|
||||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
previewContextValues={contextValues}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -845,7 +845,7 @@ function SubBlockComponent({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue}
|
previewValue={previewValue}
|
||||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
previewContextValues={contextValues}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -868,7 +868,7 @@ function SubBlockComponent({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue as any}
|
previewValue={previewValue as any}
|
||||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
previewContextValues={contextValues}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -880,7 +880,7 @@ function SubBlockComponent({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue as any}
|
previewValue={previewValue as any}
|
||||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
previewContextValues={contextValues}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -892,7 +892,7 @@ function SubBlockComponent({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue as any}
|
previewValue={previewValue as any}
|
||||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
previewContextValues={contextValues}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -917,7 +917,7 @@ function SubBlockComponent({
|
|||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue as any}
|
previewValue={previewValue as any}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
previewContextValues={contextValues}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -953,7 +953,7 @@ function SubBlockComponent({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue}
|
previewValue={previewValue}
|
||||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
previewContextValues={contextValues}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -987,7 +987,7 @@ function SubBlockComponent({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue as any}
|
previewValue={previewValue as any}
|
||||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
previewContextValues={contextValues}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -999,7 +999,7 @@ function SubBlockComponent({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
previewValue={previewValue}
|
previewValue={previewValue}
|
||||||
previewContextValues={isPreview ? subBlockValues : undefined}
|
previewContextValues={contextValues}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1059,7 +1059,8 @@ function SubBlockComponent({
|
|||||||
showCopyButton: Boolean(config.showCopyButton && config.useWebhookUrl),
|
showCopyButton: Boolean(config.showCopyButton && config.useWebhookUrl),
|
||||||
copied,
|
copied,
|
||||||
onCopy: handleCopy,
|
onCopy: handleCopy,
|
||||||
}
|
},
|
||||||
|
labelSuffix
|
||||||
)}
|
)}
|
||||||
{renderInput()}
|
{renderInput()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -571,7 +571,6 @@ export function Editor() {
|
|||||||
isPreview={false}
|
isPreview={false}
|
||||||
subBlockValues={subBlockState}
|
subBlockValues={subBlockState}
|
||||||
disabled={!canEditBlock}
|
disabled={!canEditBlock}
|
||||||
fieldDiffStatus={undefined}
|
|
||||||
allowExpandInPreview={false}
|
allowExpandInPreview={false}
|
||||||
canonicalToggle={
|
canonicalToggle={
|
||||||
isCanonicalSwap && canonicalMode && canonicalId
|
isCanonicalSwap && canonicalMode && canonicalId
|
||||||
@@ -635,7 +634,6 @@ export function Editor() {
|
|||||||
isPreview={false}
|
isPreview={false}
|
||||||
subBlockValues={subBlockState}
|
subBlockValues={subBlockState}
|
||||||
disabled={!canEditBlock}
|
disabled={!canEditBlock}
|
||||||
fieldDiffStatus={undefined}
|
|
||||||
allowExpandInPreview={false}
|
allowExpandInPreview={false}
|
||||||
/>
|
/>
|
||||||
{index < advancedOnlySubBlocks.length - 1 && (
|
{index < advancedOnlySubBlocks.length - 1 && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
@@ -46,7 +46,13 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
|||||||
|
|
||||||
const logger = createLogger('useWorkflowExecution')
|
const logger = createLogger('useWorkflowExecution')
|
||||||
|
|
||||||
// Debug state validation result
|
/**
|
||||||
|
* Module-level Set tracking which workflows have an active reconnection effect.
|
||||||
|
* Prevents multiple hook instances (from different components) from starting
|
||||||
|
* concurrent reconnection streams for the same workflow during the same mount cycle.
|
||||||
|
*/
|
||||||
|
const activeReconnections = new Set<string>()
|
||||||
|
|
||||||
interface DebugValidationResult {
|
interface DebugValidationResult {
|
||||||
isValid: boolean
|
isValid: boolean
|
||||||
error?: string
|
error?: string
|
||||||
@@ -54,7 +60,7 @@ interface DebugValidationResult {
|
|||||||
|
|
||||||
interface BlockEventHandlerConfig {
|
interface BlockEventHandlerConfig {
|
||||||
workflowId?: string
|
workflowId?: string
|
||||||
executionId?: string
|
executionIdRef: { current: string }
|
||||||
workflowEdges: Array<{ id: string; target: string; sourceHandle?: string | null }>
|
workflowEdges: Array<{ id: string; target: string; sourceHandle?: string | null }>
|
||||||
activeBlocksSet: Set<string>
|
activeBlocksSet: Set<string>
|
||||||
accumulatedBlockLogs: BlockLog[]
|
accumulatedBlockLogs: BlockLog[]
|
||||||
@@ -108,12 +114,15 @@ export function useWorkflowExecution() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const currentWorkflow = useCurrentWorkflow()
|
const currentWorkflow = useCurrentWorkflow()
|
||||||
const { activeWorkflowId, workflows } = useWorkflowRegistry()
|
const { activeWorkflowId, workflows } = useWorkflowRegistry()
|
||||||
const { toggleConsole, addConsole, updateConsole, cancelRunningEntries } =
|
const { toggleConsole, addConsole, updateConsole, cancelRunningEntries, clearExecutionEntries } =
|
||||||
useTerminalConsoleStore()
|
useTerminalConsoleStore()
|
||||||
|
const hasHydrated = useTerminalConsoleStore((s) => s._hasHydrated)
|
||||||
const { getAllVariables } = useEnvironmentStore()
|
const { getAllVariables } = useEnvironmentStore()
|
||||||
const { getVariablesByWorkflowId, variables } = useVariablesStore()
|
const { getVariablesByWorkflowId, variables } = useVariablesStore()
|
||||||
const { isExecuting, isDebugging, pendingBlocks, executor, debugContext } =
|
const { isExecuting, isDebugging, pendingBlocks, executor, debugContext } =
|
||||||
useCurrentWorkflowExecution()
|
useCurrentWorkflowExecution()
|
||||||
|
const setCurrentExecutionId = useExecutionStore((s) => s.setCurrentExecutionId)
|
||||||
|
const getCurrentExecutionId = useExecutionStore((s) => s.getCurrentExecutionId)
|
||||||
const setIsExecuting = useExecutionStore((s) => s.setIsExecuting)
|
const setIsExecuting = useExecutionStore((s) => s.setIsExecuting)
|
||||||
const setIsDebugging = useExecutionStore((s) => s.setIsDebugging)
|
const setIsDebugging = useExecutionStore((s) => s.setIsDebugging)
|
||||||
const setPendingBlocks = useExecutionStore((s) => s.setPendingBlocks)
|
const setPendingBlocks = useExecutionStore((s) => s.setPendingBlocks)
|
||||||
@@ -297,7 +306,7 @@ export function useWorkflowExecution() {
|
|||||||
(config: BlockEventHandlerConfig) => {
|
(config: BlockEventHandlerConfig) => {
|
||||||
const {
|
const {
|
||||||
workflowId,
|
workflowId,
|
||||||
executionId,
|
executionIdRef,
|
||||||
workflowEdges,
|
workflowEdges,
|
||||||
activeBlocksSet,
|
activeBlocksSet,
|
||||||
accumulatedBlockLogs,
|
accumulatedBlockLogs,
|
||||||
@@ -308,6 +317,14 @@ export function useWorkflowExecution() {
|
|||||||
onBlockCompleteCallback,
|
onBlockCompleteCallback,
|
||||||
} = config
|
} = config
|
||||||
|
|
||||||
|
/** Returns true if this execution was cancelled or superseded by another run. */
|
||||||
|
const isStaleExecution = () =>
|
||||||
|
!!(
|
||||||
|
workflowId &&
|
||||||
|
executionIdRef.current &&
|
||||||
|
useExecutionStore.getState().getCurrentExecutionId(workflowId) !== executionIdRef.current
|
||||||
|
)
|
||||||
|
|
||||||
const updateActiveBlocks = (blockId: string, isActive: boolean) => {
|
const updateActiveBlocks = (blockId: string, isActive: boolean) => {
|
||||||
if (!workflowId) return
|
if (!workflowId) return
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
@@ -360,7 +377,7 @@ export function useWorkflowExecution() {
|
|||||||
endedAt: data.endedAt,
|
endedAt: data.endedAt,
|
||||||
workflowId,
|
workflowId,
|
||||||
blockId: data.blockId,
|
blockId: data.blockId,
|
||||||
executionId,
|
executionId: executionIdRef.current,
|
||||||
blockName: data.blockName || 'Unknown Block',
|
blockName: data.blockName || 'Unknown Block',
|
||||||
blockType: data.blockType || 'unknown',
|
blockType: data.blockType || 'unknown',
|
||||||
iterationCurrent: data.iterationCurrent,
|
iterationCurrent: data.iterationCurrent,
|
||||||
@@ -383,7 +400,7 @@ export function useWorkflowExecution() {
|
|||||||
endedAt: data.endedAt,
|
endedAt: data.endedAt,
|
||||||
workflowId,
|
workflowId,
|
||||||
blockId: data.blockId,
|
blockId: data.blockId,
|
||||||
executionId,
|
executionId: executionIdRef.current,
|
||||||
blockName: data.blockName || 'Unknown Block',
|
blockName: data.blockName || 'Unknown Block',
|
||||||
blockType: data.blockType || 'unknown',
|
blockType: data.blockType || 'unknown',
|
||||||
iterationCurrent: data.iterationCurrent,
|
iterationCurrent: data.iterationCurrent,
|
||||||
@@ -410,7 +427,7 @@ export function useWorkflowExecution() {
|
|||||||
iterationType: data.iterationType,
|
iterationType: data.iterationType,
|
||||||
iterationContainerId: data.iterationContainerId,
|
iterationContainerId: data.iterationContainerId,
|
||||||
},
|
},
|
||||||
executionId
|
executionIdRef.current
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,11 +449,12 @@ export function useWorkflowExecution() {
|
|||||||
iterationType: data.iterationType,
|
iterationType: data.iterationType,
|
||||||
iterationContainerId: data.iterationContainerId,
|
iterationContainerId: data.iterationContainerId,
|
||||||
},
|
},
|
||||||
executionId
|
executionIdRef.current
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onBlockStarted = (data: BlockStartedData) => {
|
const onBlockStarted = (data: BlockStartedData) => {
|
||||||
|
if (isStaleExecution()) return
|
||||||
updateActiveBlocks(data.blockId, true)
|
updateActiveBlocks(data.blockId, true)
|
||||||
markIncomingEdges(data.blockId)
|
markIncomingEdges(data.blockId)
|
||||||
|
|
||||||
@@ -453,7 +471,7 @@ export function useWorkflowExecution() {
|
|||||||
endedAt: undefined,
|
endedAt: undefined,
|
||||||
workflowId,
|
workflowId,
|
||||||
blockId: data.blockId,
|
blockId: data.blockId,
|
||||||
executionId,
|
executionId: executionIdRef.current,
|
||||||
blockName: data.blockName || 'Unknown Block',
|
blockName: data.blockName || 'Unknown Block',
|
||||||
blockType: data.blockType || 'unknown',
|
blockType: data.blockType || 'unknown',
|
||||||
isRunning: true,
|
isRunning: true,
|
||||||
@@ -465,6 +483,7 @@ export function useWorkflowExecution() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onBlockCompleted = (data: BlockCompletedData) => {
|
const onBlockCompleted = (data: BlockCompletedData) => {
|
||||||
|
if (isStaleExecution()) return
|
||||||
updateActiveBlocks(data.blockId, false)
|
updateActiveBlocks(data.blockId, false)
|
||||||
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'success')
|
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'success')
|
||||||
|
|
||||||
@@ -495,6 +514,7 @@ export function useWorkflowExecution() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onBlockError = (data: BlockErrorData) => {
|
const onBlockError = (data: BlockErrorData) => {
|
||||||
|
if (isStaleExecution()) return
|
||||||
updateActiveBlocks(data.blockId, false)
|
updateActiveBlocks(data.blockId, false)
|
||||||
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'error')
|
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'error')
|
||||||
|
|
||||||
@@ -902,10 +922,6 @@ export function useWorkflowExecution() {
|
|||||||
|
|
||||||
// Update block logs with actual stream completion times
|
// Update block logs with actual stream completion times
|
||||||
if (result.logs && streamCompletionTimes.size > 0) {
|
if (result.logs && streamCompletionTimes.size > 0) {
|
||||||
const streamCompletionEndTime = new Date(
|
|
||||||
Math.max(...Array.from(streamCompletionTimes.values()))
|
|
||||||
).toISOString()
|
|
||||||
|
|
||||||
result.logs.forEach((log: BlockLog) => {
|
result.logs.forEach((log: BlockLog) => {
|
||||||
if (streamCompletionTimes.has(log.blockId)) {
|
if (streamCompletionTimes.has(log.blockId)) {
|
||||||
const completionTime = streamCompletionTimes.get(log.blockId)!
|
const completionTime = streamCompletionTimes.get(log.blockId)!
|
||||||
@@ -987,7 +1003,6 @@ export function useWorkflowExecution() {
|
|||||||
return { success: true, stream }
|
return { success: true, stream }
|
||||||
}
|
}
|
||||||
|
|
||||||
// For manual (non-chat) execution
|
|
||||||
const manualExecutionId = uuidv4()
|
const manualExecutionId = uuidv4()
|
||||||
try {
|
try {
|
||||||
const result = await executeWorkflow(
|
const result = await executeWorkflow(
|
||||||
@@ -1002,29 +1017,10 @@ export function useWorkflowExecution() {
|
|||||||
if (result.metadata.pendingBlocks) {
|
if (result.metadata.pendingBlocks) {
|
||||||
setPendingBlocks(activeWorkflowId, result.metadata.pendingBlocks)
|
setPendingBlocks(activeWorkflowId, result.metadata.pendingBlocks)
|
||||||
}
|
}
|
||||||
} else if (result && 'success' in result) {
|
|
||||||
setExecutionResult(result)
|
|
||||||
// Reset execution state after successful non-debug execution
|
|
||||||
setIsExecuting(activeWorkflowId, false)
|
|
||||||
setIsDebugging(activeWorkflowId, false)
|
|
||||||
setActiveBlocks(activeWorkflowId, new Set())
|
|
||||||
|
|
||||||
if (isChatExecution) {
|
|
||||||
if (!result.metadata) {
|
|
||||||
result.metadata = { duration: 0, startTime: new Date().toISOString() }
|
|
||||||
}
|
|
||||||
;(result.metadata as any).source = 'chat'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate subscription queries to update usage
|
|
||||||
setTimeout(() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
|
|
||||||
}, 1000)
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorResult = handleExecutionError(error, { executionId: manualExecutionId })
|
const errorResult = handleExecutionError(error, { executionId: manualExecutionId })
|
||||||
// Note: Error logs are already persisted server-side via execution-core.ts
|
|
||||||
return errorResult
|
return errorResult
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1275,7 +1271,7 @@ export function useWorkflowExecution() {
|
|||||||
if (activeWorkflowId) {
|
if (activeWorkflowId) {
|
||||||
logger.info('Using server-side executor')
|
logger.info('Using server-side executor')
|
||||||
|
|
||||||
const executionId = uuidv4()
|
const executionIdRef = { current: '' }
|
||||||
|
|
||||||
let executionResult: ExecutionResult = {
|
let executionResult: ExecutionResult = {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -1293,7 +1289,7 @@ export function useWorkflowExecution() {
|
|||||||
try {
|
try {
|
||||||
const blockHandlers = buildBlockEventHandlers({
|
const blockHandlers = buildBlockEventHandlers({
|
||||||
workflowId: activeWorkflowId,
|
workflowId: activeWorkflowId,
|
||||||
executionId,
|
executionIdRef,
|
||||||
workflowEdges,
|
workflowEdges,
|
||||||
activeBlocksSet,
|
activeBlocksSet,
|
||||||
accumulatedBlockLogs,
|
accumulatedBlockLogs,
|
||||||
@@ -1326,6 +1322,10 @@ export function useWorkflowExecution() {
|
|||||||
loops: clientWorkflowState.loops,
|
loops: clientWorkflowState.loops,
|
||||||
parallels: clientWorkflowState.parallels,
|
parallels: clientWorkflowState.parallels,
|
||||||
},
|
},
|
||||||
|
onExecutionId: (id) => {
|
||||||
|
executionIdRef.current = id
|
||||||
|
setCurrentExecutionId(activeWorkflowId, id)
|
||||||
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onExecutionStarted: (data) => {
|
onExecutionStarted: (data) => {
|
||||||
logger.info('Server execution started:', data)
|
logger.info('Server execution started:', data)
|
||||||
@@ -1368,6 +1368,18 @@ export function useWorkflowExecution() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onExecutionCompleted: (data) => {
|
onExecutionCompleted: (data) => {
|
||||||
|
if (
|
||||||
|
activeWorkflowId &&
|
||||||
|
executionIdRef.current &&
|
||||||
|
useExecutionStore.getState().getCurrentExecutionId(activeWorkflowId) !==
|
||||||
|
executionIdRef.current
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (activeWorkflowId) {
|
||||||
|
setCurrentExecutionId(activeWorkflowId, null)
|
||||||
|
}
|
||||||
|
|
||||||
executionResult = {
|
executionResult = {
|
||||||
success: data.success,
|
success: data.success,
|
||||||
output: data.output,
|
output: data.output,
|
||||||
@@ -1425,9 +1437,33 @@ export function useWorkflowExecution() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workflowExecState = activeWorkflowId
|
||||||
|
? useExecutionStore.getState().getWorkflowExecution(activeWorkflowId)
|
||||||
|
: null
|
||||||
|
if (activeWorkflowId && !workflowExecState?.isDebugging) {
|
||||||
|
setExecutionResult(executionResult)
|
||||||
|
setIsExecuting(activeWorkflowId, false)
|
||||||
|
setActiveBlocks(activeWorkflowId, new Set())
|
||||||
|
setTimeout(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onExecutionError: (data) => {
|
onExecutionError: (data) => {
|
||||||
|
if (
|
||||||
|
activeWorkflowId &&
|
||||||
|
executionIdRef.current &&
|
||||||
|
useExecutionStore.getState().getCurrentExecutionId(activeWorkflowId) !==
|
||||||
|
executionIdRef.current
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (activeWorkflowId) {
|
||||||
|
setCurrentExecutionId(activeWorkflowId, null)
|
||||||
|
}
|
||||||
|
|
||||||
executionResult = {
|
executionResult = {
|
||||||
success: false,
|
success: false,
|
||||||
output: {},
|
output: {},
|
||||||
@@ -1441,43 +1477,53 @@ export function useWorkflowExecution() {
|
|||||||
const isPreExecutionError = accumulatedBlockLogs.length === 0
|
const isPreExecutionError = accumulatedBlockLogs.length === 0
|
||||||
handleExecutionErrorConsole({
|
handleExecutionErrorConsole({
|
||||||
workflowId: activeWorkflowId,
|
workflowId: activeWorkflowId,
|
||||||
executionId,
|
executionId: executionIdRef.current,
|
||||||
error: data.error,
|
error: data.error,
|
||||||
durationMs: data.duration,
|
durationMs: data.duration,
|
||||||
blockLogs: accumulatedBlockLogs,
|
blockLogs: accumulatedBlockLogs,
|
||||||
isPreExecutionError,
|
isPreExecutionError,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (activeWorkflowId) {
|
||||||
|
setIsExecuting(activeWorkflowId, false)
|
||||||
|
setIsDebugging(activeWorkflowId, false)
|
||||||
|
setActiveBlocks(activeWorkflowId, new Set())
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onExecutionCancelled: (data) => {
|
onExecutionCancelled: (data) => {
|
||||||
|
if (
|
||||||
|
activeWorkflowId &&
|
||||||
|
executionIdRef.current &&
|
||||||
|
useExecutionStore.getState().getCurrentExecutionId(activeWorkflowId) !==
|
||||||
|
executionIdRef.current
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (activeWorkflowId) {
|
||||||
|
setCurrentExecutionId(activeWorkflowId, null)
|
||||||
|
}
|
||||||
|
|
||||||
handleExecutionCancelledConsole({
|
handleExecutionCancelledConsole({
|
||||||
workflowId: activeWorkflowId,
|
workflowId: activeWorkflowId,
|
||||||
executionId,
|
executionId: executionIdRef.current,
|
||||||
durationMs: data?.duration,
|
durationMs: data?.duration,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (activeWorkflowId) {
|
||||||
|
setIsExecuting(activeWorkflowId, false)
|
||||||
|
setIsDebugging(activeWorkflowId, false)
|
||||||
|
setActiveBlocks(activeWorkflowId, new Set())
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return executionResult
|
return executionResult
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Don't log abort errors - they're intentional user actions
|
|
||||||
if (error.name === 'AbortError' || error.message?.includes('aborted')) {
|
if (error.name === 'AbortError' || error.message?.includes('aborted')) {
|
||||||
logger.info('Execution aborted by user')
|
logger.info('Execution aborted by user')
|
||||||
|
return executionResult
|
||||||
// Reset execution state
|
|
||||||
if (activeWorkflowId) {
|
|
||||||
setIsExecuting(activeWorkflowId, false)
|
|
||||||
setActiveBlocks(activeWorkflowId, new Set())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return gracefully without error
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
output: {},
|
|
||||||
metadata: { duration: 0 },
|
|
||||||
logs: [],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Server-side execution failed:', error)
|
logger.error('Server-side execution failed:', error)
|
||||||
@@ -1485,7 +1531,6 @@ export function useWorkflowExecution() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: should never reach here
|
|
||||||
throw new Error('Server-side execution is required')
|
throw new Error('Server-side execution is required')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1717,25 +1762,28 @@ export function useWorkflowExecution() {
|
|||||||
* Handles cancelling the current workflow execution
|
* Handles cancelling the current workflow execution
|
||||||
*/
|
*/
|
||||||
const handleCancelExecution = useCallback(() => {
|
const handleCancelExecution = useCallback(() => {
|
||||||
|
if (!activeWorkflowId) return
|
||||||
logger.info('Workflow execution cancellation requested')
|
logger.info('Workflow execution cancellation requested')
|
||||||
|
|
||||||
// Cancel the execution stream for this workflow (server-side)
|
const storedExecutionId = getCurrentExecutionId(activeWorkflowId)
|
||||||
executionStream.cancel(activeWorkflowId ?? undefined)
|
|
||||||
|
|
||||||
// Mark current chat execution as superseded so its cleanup won't affect new executions
|
if (storedExecutionId) {
|
||||||
currentChatExecutionIdRef.current = null
|
setCurrentExecutionId(activeWorkflowId, null)
|
||||||
|
fetch(`/api/workflows/${activeWorkflowId}/executions/${storedExecutionId}/cancel`, {
|
||||||
// Mark all running entries as canceled in the terminal
|
method: 'POST',
|
||||||
if (activeWorkflowId) {
|
}).catch(() => {})
|
||||||
cancelRunningEntries(activeWorkflowId)
|
handleExecutionCancelledConsole({
|
||||||
|
workflowId: activeWorkflowId,
|
||||||
// Reset execution state - this triggers chat stream cleanup via useEffect in chat.tsx
|
executionId: storedExecutionId,
|
||||||
setIsExecuting(activeWorkflowId, false)
|
})
|
||||||
setIsDebugging(activeWorkflowId, false)
|
|
||||||
setActiveBlocks(activeWorkflowId, new Set())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If in debug mode, also reset debug state
|
executionStream.cancel(activeWorkflowId)
|
||||||
|
currentChatExecutionIdRef.current = null
|
||||||
|
setIsExecuting(activeWorkflowId, false)
|
||||||
|
setIsDebugging(activeWorkflowId, false)
|
||||||
|
setActiveBlocks(activeWorkflowId, new Set())
|
||||||
|
|
||||||
if (isDebugging) {
|
if (isDebugging) {
|
||||||
resetDebugState()
|
resetDebugState()
|
||||||
}
|
}
|
||||||
@@ -1747,7 +1795,9 @@ export function useWorkflowExecution() {
|
|||||||
setIsDebugging,
|
setIsDebugging,
|
||||||
setActiveBlocks,
|
setActiveBlocks,
|
||||||
activeWorkflowId,
|
activeWorkflowId,
|
||||||
cancelRunningEntries,
|
getCurrentExecutionId,
|
||||||
|
setCurrentExecutionId,
|
||||||
|
handleExecutionCancelledConsole,
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1847,7 +1897,7 @@ export function useWorkflowExecution() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsExecuting(workflowId, true)
|
setIsExecuting(workflowId, true)
|
||||||
const executionId = uuidv4()
|
const executionIdRef = { current: '' }
|
||||||
const accumulatedBlockLogs: BlockLog[] = []
|
const accumulatedBlockLogs: BlockLog[] = []
|
||||||
const accumulatedBlockStates = new Map<string, BlockState>()
|
const accumulatedBlockStates = new Map<string, BlockState>()
|
||||||
const executedBlockIds = new Set<string>()
|
const executedBlockIds = new Set<string>()
|
||||||
@@ -1856,7 +1906,7 @@ export function useWorkflowExecution() {
|
|||||||
try {
|
try {
|
||||||
const blockHandlers = buildBlockEventHandlers({
|
const blockHandlers = buildBlockEventHandlers({
|
||||||
workflowId,
|
workflowId,
|
||||||
executionId,
|
executionIdRef,
|
||||||
workflowEdges,
|
workflowEdges,
|
||||||
activeBlocksSet,
|
activeBlocksSet,
|
||||||
accumulatedBlockLogs,
|
accumulatedBlockLogs,
|
||||||
@@ -1871,6 +1921,10 @@ export function useWorkflowExecution() {
|
|||||||
startBlockId: blockId,
|
startBlockId: blockId,
|
||||||
sourceSnapshot: effectiveSnapshot,
|
sourceSnapshot: effectiveSnapshot,
|
||||||
input: workflowInput,
|
input: workflowInput,
|
||||||
|
onExecutionId: (id) => {
|
||||||
|
executionIdRef.current = id
|
||||||
|
setCurrentExecutionId(workflowId, id)
|
||||||
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onBlockStarted: blockHandlers.onBlockStarted,
|
onBlockStarted: blockHandlers.onBlockStarted,
|
||||||
onBlockCompleted: blockHandlers.onBlockCompleted,
|
onBlockCompleted: blockHandlers.onBlockCompleted,
|
||||||
@@ -1878,7 +1932,6 @@ export function useWorkflowExecution() {
|
|||||||
|
|
||||||
onExecutionCompleted: (data) => {
|
onExecutionCompleted: (data) => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Add the start block (trigger) to executed blocks
|
|
||||||
executedBlockIds.add(blockId)
|
executedBlockIds.add(blockId)
|
||||||
|
|
||||||
const mergedBlockStates: Record<string, BlockState> = {
|
const mergedBlockStates: Record<string, BlockState> = {
|
||||||
@@ -1902,6 +1955,10 @@ export function useWorkflowExecution() {
|
|||||||
}
|
}
|
||||||
setLastExecutionSnapshot(workflowId, updatedSnapshot)
|
setLastExecutionSnapshot(workflowId, updatedSnapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCurrentExecutionId(workflowId, null)
|
||||||
|
setIsExecuting(workflowId, false)
|
||||||
|
setActiveBlocks(workflowId, new Set())
|
||||||
},
|
},
|
||||||
|
|
||||||
onExecutionError: (data) => {
|
onExecutionError: (data) => {
|
||||||
@@ -1921,19 +1978,27 @@ export function useWorkflowExecution() {
|
|||||||
|
|
||||||
handleExecutionErrorConsole({
|
handleExecutionErrorConsole({
|
||||||
workflowId,
|
workflowId,
|
||||||
executionId,
|
executionId: executionIdRef.current,
|
||||||
error: data.error,
|
error: data.error,
|
||||||
durationMs: data.duration,
|
durationMs: data.duration,
|
||||||
blockLogs: accumulatedBlockLogs,
|
blockLogs: accumulatedBlockLogs,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setCurrentExecutionId(workflowId, null)
|
||||||
|
setIsExecuting(workflowId, false)
|
||||||
|
setActiveBlocks(workflowId, new Set())
|
||||||
},
|
},
|
||||||
|
|
||||||
onExecutionCancelled: (data) => {
|
onExecutionCancelled: (data) => {
|
||||||
handleExecutionCancelledConsole({
|
handleExecutionCancelledConsole({
|
||||||
workflowId,
|
workflowId,
|
||||||
executionId,
|
executionId: executionIdRef.current,
|
||||||
durationMs: data?.duration,
|
durationMs: data?.duration,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setCurrentExecutionId(workflowId, null)
|
||||||
|
setIsExecuting(workflowId, false)
|
||||||
|
setActiveBlocks(workflowId, new Set())
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1942,14 +2007,20 @@ export function useWorkflowExecution() {
|
|||||||
logger.error('Run-from-block failed:', error)
|
logger.error('Run-from-block failed:', error)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsExecuting(workflowId, false)
|
const currentId = getCurrentExecutionId(workflowId)
|
||||||
setActiveBlocks(workflowId, new Set())
|
if (currentId === null || currentId === executionIdRef.current) {
|
||||||
|
setCurrentExecutionId(workflowId, null)
|
||||||
|
setIsExecuting(workflowId, false)
|
||||||
|
setActiveBlocks(workflowId, new Set())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
getLastExecutionSnapshot,
|
getLastExecutionSnapshot,
|
||||||
setLastExecutionSnapshot,
|
setLastExecutionSnapshot,
|
||||||
clearLastExecutionSnapshot,
|
clearLastExecutionSnapshot,
|
||||||
|
getCurrentExecutionId,
|
||||||
|
setCurrentExecutionId,
|
||||||
setIsExecuting,
|
setIsExecuting,
|
||||||
setActiveBlocks,
|
setActiveBlocks,
|
||||||
setBlockRunStatus,
|
setBlockRunStatus,
|
||||||
@@ -1979,29 +2050,213 @@ export function useWorkflowExecution() {
|
|||||||
|
|
||||||
const executionId = uuidv4()
|
const executionId = uuidv4()
|
||||||
try {
|
try {
|
||||||
const result = await executeWorkflow(
|
await executeWorkflow(undefined, undefined, executionId, undefined, 'manual', blockId)
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
executionId,
|
|
||||||
undefined,
|
|
||||||
'manual',
|
|
||||||
blockId
|
|
||||||
)
|
|
||||||
if (result && 'success' in result) {
|
|
||||||
setExecutionResult(result)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorResult = handleExecutionError(error, { executionId })
|
const errorResult = handleExecutionError(error, { executionId })
|
||||||
return errorResult
|
return errorResult
|
||||||
} finally {
|
} finally {
|
||||||
|
setCurrentExecutionId(workflowId, null)
|
||||||
setIsExecuting(workflowId, false)
|
setIsExecuting(workflowId, false)
|
||||||
setIsDebugging(workflowId, false)
|
setIsDebugging(workflowId, false)
|
||||||
setActiveBlocks(workflowId, new Set())
|
setActiveBlocks(workflowId, new Set())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeWorkflowId, setExecutionResult, setIsExecuting, setIsDebugging, setActiveBlocks]
|
[
|
||||||
|
activeWorkflowId,
|
||||||
|
setCurrentExecutionId,
|
||||||
|
setExecutionResult,
|
||||||
|
setIsExecuting,
|
||||||
|
setIsDebugging,
|
||||||
|
setActiveBlocks,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeWorkflowId || !hasHydrated) return
|
||||||
|
|
||||||
|
const entries = useTerminalConsoleStore.getState().entries
|
||||||
|
const runningEntries = entries.filter(
|
||||||
|
(e) => e.isRunning && e.workflowId === activeWorkflowId && e.executionId
|
||||||
|
)
|
||||||
|
if (runningEntries.length === 0) return
|
||||||
|
|
||||||
|
if (activeReconnections.has(activeWorkflowId)) return
|
||||||
|
activeReconnections.add(activeWorkflowId)
|
||||||
|
|
||||||
|
executionStream.cancel(activeWorkflowId)
|
||||||
|
|
||||||
|
const sorted = [...runningEntries].sort((a, b) => {
|
||||||
|
const aTime = a.startedAt ? new Date(a.startedAt).getTime() : 0
|
||||||
|
const bTime = b.startedAt ? new Date(b.startedAt).getTime() : 0
|
||||||
|
return bTime - aTime
|
||||||
|
})
|
||||||
|
const executionId = sorted[0].executionId!
|
||||||
|
|
||||||
|
const otherExecutionIds = new Set(
|
||||||
|
sorted.filter((e) => e.executionId !== executionId).map((e) => e.executionId!)
|
||||||
|
)
|
||||||
|
if (otherExecutionIds.size > 0) {
|
||||||
|
cancelRunningEntries(activeWorkflowId)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentExecutionId(activeWorkflowId, executionId)
|
||||||
|
setIsExecuting(activeWorkflowId, true)
|
||||||
|
|
||||||
|
const workflowEdges = useWorkflowStore.getState().edges
|
||||||
|
const activeBlocksSet = new Set<string>()
|
||||||
|
const accumulatedBlockLogs: BlockLog[] = []
|
||||||
|
const accumulatedBlockStates = new Map<string, BlockState>()
|
||||||
|
const executedBlockIds = new Set<string>()
|
||||||
|
|
||||||
|
const executionIdRef = { current: executionId }
|
||||||
|
|
||||||
|
const handlers = buildBlockEventHandlers({
|
||||||
|
workflowId: activeWorkflowId,
|
||||||
|
executionIdRef,
|
||||||
|
workflowEdges,
|
||||||
|
activeBlocksSet,
|
||||||
|
accumulatedBlockLogs,
|
||||||
|
accumulatedBlockStates,
|
||||||
|
executedBlockIds,
|
||||||
|
consoleMode: 'update',
|
||||||
|
includeStartConsoleEntry: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const originalEntries = entries
|
||||||
|
.filter((e) => e.executionId === executionId)
|
||||||
|
.map((e) => ({ ...e }))
|
||||||
|
|
||||||
|
let cleared = false
|
||||||
|
let reconnectionComplete = false
|
||||||
|
let cleanupRan = false
|
||||||
|
const clearOnce = () => {
|
||||||
|
if (!cleared) {
|
||||||
|
cleared = true
|
||||||
|
clearExecutionEntries(executionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconnectWorkflowId = activeWorkflowId
|
||||||
|
|
||||||
|
executionStream
|
||||||
|
.reconnect({
|
||||||
|
workflowId: reconnectWorkflowId,
|
||||||
|
executionId,
|
||||||
|
callbacks: {
|
||||||
|
onBlockStarted: (data) => {
|
||||||
|
clearOnce()
|
||||||
|
handlers.onBlockStarted(data)
|
||||||
|
},
|
||||||
|
onBlockCompleted: (data) => {
|
||||||
|
clearOnce()
|
||||||
|
handlers.onBlockCompleted(data)
|
||||||
|
},
|
||||||
|
onBlockError: (data) => {
|
||||||
|
clearOnce()
|
||||||
|
handlers.onBlockError(data)
|
||||||
|
},
|
||||||
|
onExecutionCompleted: () => {
|
||||||
|
const currentId = useExecutionStore
|
||||||
|
.getState()
|
||||||
|
.getCurrentExecutionId(reconnectWorkflowId)
|
||||||
|
if (currentId !== executionId) {
|
||||||
|
reconnectionComplete = true
|
||||||
|
activeReconnections.delete(reconnectWorkflowId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearOnce()
|
||||||
|
reconnectionComplete = true
|
||||||
|
activeReconnections.delete(reconnectWorkflowId)
|
||||||
|
setCurrentExecutionId(reconnectWorkflowId, null)
|
||||||
|
setIsExecuting(reconnectWorkflowId, false)
|
||||||
|
setActiveBlocks(reconnectWorkflowId, new Set())
|
||||||
|
},
|
||||||
|
onExecutionError: (data) => {
|
||||||
|
const currentId = useExecutionStore
|
||||||
|
.getState()
|
||||||
|
.getCurrentExecutionId(reconnectWorkflowId)
|
||||||
|
if (currentId !== executionId) {
|
||||||
|
reconnectionComplete = true
|
||||||
|
activeReconnections.delete(reconnectWorkflowId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearOnce()
|
||||||
|
reconnectionComplete = true
|
||||||
|
activeReconnections.delete(reconnectWorkflowId)
|
||||||
|
setCurrentExecutionId(reconnectWorkflowId, null)
|
||||||
|
setIsExecuting(reconnectWorkflowId, false)
|
||||||
|
setActiveBlocks(reconnectWorkflowId, new Set())
|
||||||
|
handleExecutionErrorConsole({
|
||||||
|
workflowId: reconnectWorkflowId,
|
||||||
|
executionId,
|
||||||
|
error: data.error,
|
||||||
|
blockLogs: accumulatedBlockLogs,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onExecutionCancelled: () => {
|
||||||
|
const currentId = useExecutionStore
|
||||||
|
.getState()
|
||||||
|
.getCurrentExecutionId(reconnectWorkflowId)
|
||||||
|
if (currentId !== executionId) {
|
||||||
|
reconnectionComplete = true
|
||||||
|
activeReconnections.delete(reconnectWorkflowId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearOnce()
|
||||||
|
reconnectionComplete = true
|
||||||
|
activeReconnections.delete(reconnectWorkflowId)
|
||||||
|
setCurrentExecutionId(reconnectWorkflowId, null)
|
||||||
|
setIsExecuting(reconnectWorkflowId, false)
|
||||||
|
setActiveBlocks(reconnectWorkflowId, new Set())
|
||||||
|
handleExecutionCancelledConsole({
|
||||||
|
workflowId: reconnectWorkflowId,
|
||||||
|
executionId,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn('Execution reconnection failed', { executionId, error })
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (reconnectionComplete || cleanupRan) return
|
||||||
|
const currentId = useExecutionStore.getState().getCurrentExecutionId(reconnectWorkflowId)
|
||||||
|
if (currentId !== executionId) return
|
||||||
|
reconnectionComplete = true
|
||||||
|
activeReconnections.delete(reconnectWorkflowId)
|
||||||
|
clearExecutionEntries(executionId)
|
||||||
|
for (const entry of originalEntries) {
|
||||||
|
addConsole({
|
||||||
|
workflowId: entry.workflowId,
|
||||||
|
blockId: entry.blockId,
|
||||||
|
blockName: entry.blockName,
|
||||||
|
blockType: entry.blockType,
|
||||||
|
executionId: entry.executionId,
|
||||||
|
executionOrder: entry.executionOrder,
|
||||||
|
isRunning: false,
|
||||||
|
warning: 'Execution result unavailable — check the logs page',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setCurrentExecutionId(reconnectWorkflowId, null)
|
||||||
|
setIsExecuting(reconnectWorkflowId, false)
|
||||||
|
setActiveBlocks(reconnectWorkflowId, new Set())
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanupRan = true
|
||||||
|
executionStream.cancel(reconnectWorkflowId)
|
||||||
|
activeReconnections.delete(reconnectWorkflowId)
|
||||||
|
|
||||||
|
if (cleared && !reconnectionComplete) {
|
||||||
|
clearExecutionEntries(executionId)
|
||||||
|
for (const entry of originalEntries) {
|
||||||
|
addConsole(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeWorkflowId, hasHydrated])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isExecuting,
|
isExecuting,
|
||||||
isDebugging,
|
isDebugging,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { CancelSubscription } from './cancel-subscription'
|
export { CancelSubscription } from './cancel-subscription'
|
||||||
export { CreditBalance } from './credit-balance'
|
export { CreditBalance } from './credit-balance'
|
||||||
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
|
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
|
||||||
|
export { ReferralCode } from './referral-code'
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ReferralCode } from './referral-code'
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { Button, Input, Label } from '@/components/emcn'
|
||||||
|
|
||||||
|
const logger = createLogger('ReferralCode')
|
||||||
|
|
||||||
|
interface ReferralCodeProps {
|
||||||
|
onRedeemComplete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline referral/promo code entry field with redeem button.
|
||||||
|
* One-time use per account — shows success or "already redeemed" state.
|
||||||
|
*/
|
||||||
|
export function ReferralCode({ onRedeemComplete }: ReferralCodeProps) {
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [isRedeeming, setIsRedeeming] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState<{ bonusAmount: number } | null>(null)
|
||||||
|
|
||||||
|
const handleRedeem = async () => {
|
||||||
|
const trimmed = code.trim()
|
||||||
|
if (!trimmed || isRedeeming) return
|
||||||
|
|
||||||
|
setIsRedeeming(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/referral-code/redeem', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code: trimmed }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to redeem code')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.redeemed) {
|
||||||
|
setSuccess({ bonusAmount: data.bonusAmount })
|
||||||
|
setCode('')
|
||||||
|
onRedeemComplete?.()
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Code could not be redeemed')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Referral code redemption failed', { error: err })
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to redeem code')
|
||||||
|
} finally {
|
||||||
|
setIsRedeeming(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<Label>Referral Code</Label>
|
||||||
|
<span className='text-[12px] text-[var(--text-secondary)]'>
|
||||||
|
+${success.bonusAmount} credits applied
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
<div className='flex items-center justify-between gap-[12px]'>
|
||||||
|
<Label className='shrink-0'>Referral Code</Label>
|
||||||
|
<div className='flex items-center gap-[8px]'>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCode(e.target.value)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleRedeem()
|
||||||
|
}}
|
||||||
|
placeholder='Enter code'
|
||||||
|
className='h-[32px] w-[140px] text-[12px]'
|
||||||
|
disabled={isRedeeming}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant='active'
|
||||||
|
className='h-[32px] shrink-0 rounded-[6px] text-[12px]'
|
||||||
|
onClick={handleRedeem}
|
||||||
|
disabled={isRedeeming || !code.trim()}
|
||||||
|
>
|
||||||
|
{isRedeeming ? 'Redeeming...' : 'Redeem'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='mt-[4px] min-h-[18px] text-right'>
|
||||||
|
{error && <span className='text-[11px] text-[var(--text-error)]'>{error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
CancelSubscription,
|
CancelSubscription,
|
||||||
CreditBalance,
|
CreditBalance,
|
||||||
PlanCard,
|
PlanCard,
|
||||||
|
ReferralCode,
|
||||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components'
|
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components'
|
||||||
import {
|
import {
|
||||||
ENTERPRISE_PLAN_FEATURES,
|
ENTERPRISE_PLAN_FEATURES,
|
||||||
@@ -549,6 +550,10 @@ export function Subscription() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!subscription.isEnterprise && (
|
||||||
|
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Next Billing Date - hidden from team members */}
|
{/* Next Billing Date - hidden from team members */}
|
||||||
{subscription.isPaid &&
|
{subscription.isPaid &&
|
||||||
subscriptionData?.data?.periodEnd &&
|
subscriptionData?.data?.periodEnd &&
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { useEffect } from 'react'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useSession } from '@/lib/auth/auth-client'
|
import { useSession } from '@/lib/auth/auth-client'
|
||||||
|
import { useReferralAttribution } from '@/hooks/use-referral-attribution'
|
||||||
|
|
||||||
const logger = createLogger('WorkspacePage')
|
const logger = createLogger('WorkspacePage')
|
||||||
|
|
||||||
export default function WorkspacePage() {
|
export default function WorkspacePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data: session, isPending } = useSession()
|
const { data: session, isPending } = useSession()
|
||||||
|
useReferralAttribution()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const redirectToFirstWorkspace = async () => {
|
const redirectToFirstWorkspace = async () => {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { BlockConfig } from '@/blocks/types'
|
|||||||
import { AuthMode } from '@/blocks/types'
|
import { AuthMode } from '@/blocks/types'
|
||||||
import { normalizeFileInput } from '@/blocks/utils'
|
import { normalizeFileInput } from '@/blocks/utils'
|
||||||
import type { ConfluenceResponse } from '@/tools/confluence/types'
|
import type { ConfluenceResponse } from '@/tools/confluence/types'
|
||||||
import { getTrigger } from '@/triggers'
|
|
||||||
|
|
||||||
export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||||
type: 'confluence',
|
type: 'confluence',
|
||||||
@@ -395,7 +394,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
// Page Property Operations
|
// Page Property Operations
|
||||||
{ label: 'List Page Properties', id: 'list_page_properties' },
|
{ label: 'List Page Properties', id: 'list_page_properties' },
|
||||||
{ label: 'Create Page Property', id: 'create_page_property' },
|
{ label: 'Create Page Property', id: 'create_page_property' },
|
||||||
{ label: 'Update Page Property', id: 'update_page_property' },
|
|
||||||
{ label: 'Delete Page Property', id: 'delete_page_property' },
|
{ label: 'Delete Page Property', id: 'delete_page_property' },
|
||||||
// Search Operations
|
// Search Operations
|
||||||
{ label: 'Search Content', id: 'search' },
|
{ label: 'Search Content', id: 'search' },
|
||||||
@@ -404,8 +402,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
{ label: 'List Blog Posts', id: 'list_blogposts' },
|
{ label: 'List Blog Posts', id: 'list_blogposts' },
|
||||||
{ label: 'Get Blog Post', id: 'get_blogpost' },
|
{ label: 'Get Blog Post', id: 'get_blogpost' },
|
||||||
{ label: 'Create Blog Post', id: 'create_blogpost' },
|
{ label: 'Create Blog Post', id: 'create_blogpost' },
|
||||||
{ label: 'Update Blog Post', id: 'update_blogpost' },
|
|
||||||
{ label: 'Delete Blog Post', id: 'delete_blogpost' },
|
|
||||||
{ label: 'List Blog Posts in Space', id: 'list_blogposts_in_space' },
|
{ label: 'List Blog Posts in Space', id: 'list_blogposts_in_space' },
|
||||||
// Comment Operations
|
// Comment Operations
|
||||||
{ label: 'Create Comment', id: 'create_comment' },
|
{ label: 'Create Comment', id: 'create_comment' },
|
||||||
@@ -488,9 +484,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
'list_pages_in_space',
|
'list_pages_in_space',
|
||||||
'list_blogposts',
|
'list_blogposts',
|
||||||
'get_blogpost',
|
'get_blogpost',
|
||||||
'create_blogpost',
|
|
||||||
'update_blogpost',
|
|
||||||
'delete_blogpost',
|
|
||||||
'list_blogposts_in_space',
|
'list_blogposts_in_space',
|
||||||
'search',
|
'search',
|
||||||
'search_in_space',
|
'search_in_space',
|
||||||
@@ -515,7 +508,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
'add_label',
|
'add_label',
|
||||||
'delete_label',
|
'delete_label',
|
||||||
'delete_page_property',
|
'delete_page_property',
|
||||||
'update_page_property',
|
|
||||||
'get_page_children',
|
'get_page_children',
|
||||||
'get_page_ancestors',
|
'get_page_ancestors',
|
||||||
'list_page_versions',
|
'list_page_versions',
|
||||||
@@ -538,9 +530,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
'list_pages_in_space',
|
'list_pages_in_space',
|
||||||
'list_blogposts',
|
'list_blogposts',
|
||||||
'get_blogpost',
|
'get_blogpost',
|
||||||
'create_blogpost',
|
|
||||||
'update_blogpost',
|
|
||||||
'delete_blogpost',
|
|
||||||
'list_blogposts_in_space',
|
'list_blogposts_in_space',
|
||||||
'search',
|
'search',
|
||||||
'search_in_space',
|
'search_in_space',
|
||||||
@@ -565,7 +554,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
'add_label',
|
'add_label',
|
||||||
'delete_label',
|
'delete_label',
|
||||||
'delete_page_property',
|
'delete_page_property',
|
||||||
'update_page_property',
|
|
||||||
'get_page_children',
|
'get_page_children',
|
||||||
'get_page_ancestors',
|
'get_page_ancestors',
|
||||||
'list_page_versions',
|
'list_page_versions',
|
||||||
@@ -600,10 +588,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
placeholder: 'Enter blog post ID',
|
placeholder: 'Enter blog post ID',
|
||||||
required: true,
|
required: true,
|
||||||
condition: {
|
condition: { field: 'operation', value: 'get_blogpost' },
|
||||||
field: 'operation',
|
|
||||||
value: ['get_blogpost', 'update_blogpost', 'delete_blogpost'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'versionNumber',
|
id: 'versionNumber',
|
||||||
@@ -619,7 +604,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
placeholder: 'Enter property key/name',
|
placeholder: 'Enter property key/name',
|
||||||
required: true,
|
required: true,
|
||||||
condition: { field: 'operation', value: ['create_page_property', 'update_page_property'] },
|
condition: { field: 'operation', value: 'create_page_property' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'propertyValue',
|
id: 'propertyValue',
|
||||||
@@ -627,46 +612,29 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
type: 'long-input',
|
type: 'long-input',
|
||||||
placeholder: 'Enter property value (JSON supported)',
|
placeholder: 'Enter property value (JSON supported)',
|
||||||
required: true,
|
required: true,
|
||||||
condition: { field: 'operation', value: ['create_page_property', 'update_page_property'] },
|
condition: { field: 'operation', value: 'create_page_property' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'propertyId',
|
id: 'propertyId',
|
||||||
title: 'Property ID',
|
title: 'Property ID',
|
||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
placeholder: 'Enter property ID',
|
placeholder: 'Enter property ID to delete',
|
||||||
required: true,
|
required: true,
|
||||||
condition: {
|
condition: { field: 'operation', value: 'delete_page_property' },
|
||||||
field: 'operation',
|
|
||||||
value: ['delete_page_property', 'update_page_property'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'propertyVersionNumber',
|
|
||||||
title: 'Property Version Number',
|
|
||||||
type: 'short-input',
|
|
||||||
placeholder: 'Enter current version number of the property',
|
|
||||||
required: true,
|
|
||||||
condition: { field: 'operation', value: 'update_page_property' },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'title',
|
id: 'title',
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
placeholder: 'Enter title',
|
placeholder: 'Enter title',
|
||||||
condition: {
|
condition: { field: 'operation', value: ['create', 'update', 'create_blogpost'] },
|
||||||
field: 'operation',
|
|
||||||
value: ['create', 'update', 'create_blogpost', 'update_blogpost'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'content',
|
id: 'content',
|
||||||
title: 'Content',
|
title: 'Content',
|
||||||
type: 'long-input',
|
type: 'long-input',
|
||||||
placeholder: 'Enter content',
|
placeholder: 'Enter content',
|
||||||
condition: {
|
condition: { field: 'operation', value: ['create', 'update', 'create_blogpost'] },
|
||||||
field: 'operation',
|
|
||||||
value: ['create', 'update', 'create_blogpost', 'update_blogpost'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'parentId',
|
id: 'parentId',
|
||||||
@@ -779,7 +747,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
{ label: 'Draft', id: 'draft' },
|
{ label: 'Draft', id: 'draft' },
|
||||||
],
|
],
|
||||||
value: () => 'current',
|
value: () => 'current',
|
||||||
condition: { field: 'operation', value: ['create_blogpost', 'update_blogpost'] },
|
condition: { field: 'operation', value: 'create_blogpost' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'purge',
|
id: 'purge',
|
||||||
@@ -848,46 +816,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Trigger subBlocks
|
|
||||||
...getTrigger('confluence_page_created').subBlocks,
|
|
||||||
...getTrigger('confluence_page_updated').subBlocks,
|
|
||||||
...getTrigger('confluence_page_removed').subBlocks,
|
|
||||||
...getTrigger('confluence_page_moved').subBlocks,
|
|
||||||
...getTrigger('confluence_comment_created').subBlocks,
|
|
||||||
...getTrigger('confluence_comment_removed').subBlocks,
|
|
||||||
...getTrigger('confluence_blog_created').subBlocks,
|
|
||||||
...getTrigger('confluence_blog_updated').subBlocks,
|
|
||||||
...getTrigger('confluence_blog_removed').subBlocks,
|
|
||||||
...getTrigger('confluence_attachment_created').subBlocks,
|
|
||||||
...getTrigger('confluence_attachment_removed').subBlocks,
|
|
||||||
...getTrigger('confluence_space_created').subBlocks,
|
|
||||||
...getTrigger('confluence_space_updated').subBlocks,
|
|
||||||
...getTrigger('confluence_label_added').subBlocks,
|
|
||||||
...getTrigger('confluence_label_removed').subBlocks,
|
|
||||||
...getTrigger('confluence_webhook').subBlocks,
|
|
||||||
],
|
],
|
||||||
triggers: {
|
|
||||||
enabled: true,
|
|
||||||
available: [
|
|
||||||
'confluence_page_created',
|
|
||||||
'confluence_page_updated',
|
|
||||||
'confluence_page_removed',
|
|
||||||
'confluence_page_moved',
|
|
||||||
'confluence_comment_created',
|
|
||||||
'confluence_comment_removed',
|
|
||||||
'confluence_blog_created',
|
|
||||||
'confluence_blog_updated',
|
|
||||||
'confluence_blog_removed',
|
|
||||||
'confluence_attachment_created',
|
|
||||||
'confluence_attachment_removed',
|
|
||||||
'confluence_space_created',
|
|
||||||
'confluence_space_updated',
|
|
||||||
'confluence_label_added',
|
|
||||||
'confluence_label_removed',
|
|
||||||
'confluence_webhook',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
tools: {
|
tools: {
|
||||||
access: [
|
access: [
|
||||||
// Page Tools
|
// Page Tools
|
||||||
@@ -904,7 +833,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
// Property Tools
|
// Property Tools
|
||||||
'confluence_list_page_properties',
|
'confluence_list_page_properties',
|
||||||
'confluence_create_page_property',
|
'confluence_create_page_property',
|
||||||
'confluence_update_page_property',
|
|
||||||
'confluence_delete_page_property',
|
'confluence_delete_page_property',
|
||||||
// Search Tools
|
// Search Tools
|
||||||
'confluence_search',
|
'confluence_search',
|
||||||
@@ -913,8 +841,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
'confluence_list_blogposts',
|
'confluence_list_blogposts',
|
||||||
'confluence_get_blogpost',
|
'confluence_get_blogpost',
|
||||||
'confluence_create_blogpost',
|
'confluence_create_blogpost',
|
||||||
'confluence_update_blogpost',
|
|
||||||
'confluence_delete_blogpost',
|
|
||||||
'confluence_list_blogposts_in_space',
|
'confluence_list_blogposts_in_space',
|
||||||
// Comment Tools
|
// Comment Tools
|
||||||
'confluence_create_comment',
|
'confluence_create_comment',
|
||||||
@@ -963,8 +889,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
return 'confluence_list_page_properties'
|
return 'confluence_list_page_properties'
|
||||||
case 'create_page_property':
|
case 'create_page_property':
|
||||||
return 'confluence_create_page_property'
|
return 'confluence_create_page_property'
|
||||||
case 'update_page_property':
|
|
||||||
return 'confluence_update_page_property'
|
|
||||||
case 'delete_page_property':
|
case 'delete_page_property':
|
||||||
return 'confluence_delete_page_property'
|
return 'confluence_delete_page_property'
|
||||||
// Search Operations
|
// Search Operations
|
||||||
@@ -979,10 +903,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
return 'confluence_get_blogpost'
|
return 'confluence_get_blogpost'
|
||||||
case 'create_blogpost':
|
case 'create_blogpost':
|
||||||
return 'confluence_create_blogpost'
|
return 'confluence_create_blogpost'
|
||||||
case 'update_blogpost':
|
|
||||||
return 'confluence_update_blogpost'
|
|
||||||
case 'delete_blogpost':
|
|
||||||
return 'confluence_delete_blogpost'
|
|
||||||
case 'list_blogposts_in_space':
|
case 'list_blogposts_in_space':
|
||||||
return 'confluence_list_blogposts_in_space'
|
return 'confluence_list_blogposts_in_space'
|
||||||
// Comment Operations
|
// Comment Operations
|
||||||
@@ -1034,7 +954,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
propertyKey,
|
propertyKey,
|
||||||
propertyValue,
|
propertyValue,
|
||||||
propertyId,
|
propertyId,
|
||||||
propertyVersionNumber,
|
|
||||||
labelPrefix,
|
labelPrefix,
|
||||||
labelId,
|
labelId,
|
||||||
blogPostStatus,
|
blogPostStatus,
|
||||||
@@ -1066,25 +985,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operation === 'update_blogpost') {
|
|
||||||
return {
|
|
||||||
credential,
|
|
||||||
operation,
|
|
||||||
blogPostId,
|
|
||||||
status: blogPostStatus || undefined,
|
|
||||||
...rest,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operation === 'delete_blogpost') {
|
|
||||||
return {
|
|
||||||
credential,
|
|
||||||
operation,
|
|
||||||
blogPostId,
|
|
||||||
...rest,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operation === 'delete') {
|
if (operation === 'delete') {
|
||||||
return {
|
return {
|
||||||
credential,
|
credential,
|
||||||
@@ -1145,24 +1045,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operation === 'update_page_property') {
|
|
||||||
if (!propertyKey) {
|
|
||||||
throw new Error('Property key is required for this operation.')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
credential,
|
|
||||||
pageId: effectivePageId,
|
|
||||||
operation,
|
|
||||||
propertyId,
|
|
||||||
key: propertyKey,
|
|
||||||
value: propertyValue,
|
|
||||||
versionNumber: propertyVersionNumber
|
|
||||||
? Number.parseInt(String(propertyVersionNumber), 10)
|
|
||||||
: undefined,
|
|
||||||
...rest,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operation === 'delete_page_property') {
|
if (operation === 'delete_page_property') {
|
||||||
return {
|
return {
|
||||||
credential,
|
credential,
|
||||||
@@ -1243,10 +1125,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
|||||||
labelId: { type: 'string', description: 'Label identifier' },
|
labelId: { type: 'string', description: 'Label identifier' },
|
||||||
labelPrefix: { type: 'string', description: 'Label prefix (global, my, team, system)' },
|
labelPrefix: { type: 'string', description: 'Label prefix (global, my, team, system)' },
|
||||||
propertyId: { type: 'string', description: 'Property identifier' },
|
propertyId: { type: 'string', description: 'Property identifier' },
|
||||||
propertyVersionNumber: {
|
|
||||||
type: 'number',
|
|
||||||
description: 'Current version number of the property',
|
|
||||||
},
|
|
||||||
blogPostStatus: { type: 'string', description: 'Blog post status (current or draft)' },
|
blogPostStatus: { type: 'string', description: 'Blog post status (current or draft)' },
|
||||||
purge: { type: 'boolean', description: 'Permanently delete instead of moving to trash' },
|
purge: { type: 'boolean', description: 'Permanently delete instead of moving to trash' },
|
||||||
bodyFormat: { type: 'string', description: 'Body format for comments' },
|
bodyFormat: { type: 'string', description: 'Body format for comments' },
|
||||||
|
|||||||
201
apps/sim/blocks/blocks/google_books.ts
Normal file
201
apps/sim/blocks/blocks/google_books.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { GoogleBooksIcon } from '@/components/icons'
|
||||||
|
import type { BlockConfig } from '@/blocks/types'
|
||||||
|
import { AuthMode } from '@/blocks/types'
|
||||||
|
|
||||||
|
export const GoogleBooksBlock: BlockConfig = {
|
||||||
|
type: 'google_books',
|
||||||
|
name: 'Google Books',
|
||||||
|
description: 'Search and retrieve book information',
|
||||||
|
authMode: AuthMode.ApiKey,
|
||||||
|
longDescription:
|
||||||
|
'Search for books using the Google Books API. Find volumes by title, author, ISBN, or keywords, and retrieve detailed information about specific books including descriptions, ratings, and publication details.',
|
||||||
|
docsLink: 'https://docs.sim.ai/tools/google_books',
|
||||||
|
category: 'tools',
|
||||||
|
bgColor: '#E0E0E0',
|
||||||
|
icon: GoogleBooksIcon,
|
||||||
|
|
||||||
|
subBlocks: [
|
||||||
|
{
|
||||||
|
id: 'operation',
|
||||||
|
title: 'Operation',
|
||||||
|
type: 'dropdown',
|
||||||
|
options: [
|
||||||
|
{ label: 'Search Volumes', id: 'volume_search' },
|
||||||
|
{ label: 'Get Volume Details', id: 'volume_details' },
|
||||||
|
],
|
||||||
|
value: () => 'volume_search',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'apiKey',
|
||||||
|
title: 'API Key',
|
||||||
|
type: 'short-input',
|
||||||
|
password: true,
|
||||||
|
placeholder: 'Enter your Google Books API key',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'query',
|
||||||
|
title: 'Search Query',
|
||||||
|
type: 'short-input',
|
||||||
|
placeholder: 'e.g., intitle:harry potter inauthor:rowling',
|
||||||
|
condition: { field: 'operation', value: 'volume_search' },
|
||||||
|
required: { field: 'operation', value: 'volume_search' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'filter',
|
||||||
|
title: 'Filter',
|
||||||
|
type: 'dropdown',
|
||||||
|
options: [
|
||||||
|
{ label: 'None', id: '' },
|
||||||
|
{ label: 'Partial Preview', id: 'partial' },
|
||||||
|
{ label: 'Full Preview', id: 'full' },
|
||||||
|
{ label: 'Free eBooks', id: 'free-ebooks' },
|
||||||
|
{ label: 'Paid eBooks', id: 'paid-ebooks' },
|
||||||
|
{ label: 'All eBooks', id: 'ebooks' },
|
||||||
|
],
|
||||||
|
condition: { field: 'operation', value: 'volume_search' },
|
||||||
|
mode: 'advanced',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'printType',
|
||||||
|
title: 'Print Type',
|
||||||
|
type: 'dropdown',
|
||||||
|
options: [
|
||||||
|
{ label: 'All', id: 'all' },
|
||||||
|
{ label: 'Books', id: 'books' },
|
||||||
|
{ label: 'Magazines', id: 'magazines' },
|
||||||
|
],
|
||||||
|
value: () => 'all',
|
||||||
|
condition: { field: 'operation', value: 'volume_search' },
|
||||||
|
mode: 'advanced',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orderBy',
|
||||||
|
title: 'Order By',
|
||||||
|
type: 'dropdown',
|
||||||
|
options: [
|
||||||
|
{ label: 'Relevance', id: 'relevance' },
|
||||||
|
{ label: 'Newest', id: 'newest' },
|
||||||
|
],
|
||||||
|
value: () => 'relevance',
|
||||||
|
condition: { field: 'operation', value: 'volume_search' },
|
||||||
|
mode: 'advanced',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'maxResults',
|
||||||
|
title: 'Max Results',
|
||||||
|
type: 'short-input',
|
||||||
|
placeholder: 'Number of results (1-40)',
|
||||||
|
condition: { field: 'operation', value: 'volume_search' },
|
||||||
|
mode: 'advanced',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'startIndex',
|
||||||
|
title: 'Start Index',
|
||||||
|
type: 'short-input',
|
||||||
|
placeholder: 'Starting index for pagination',
|
||||||
|
condition: { field: 'operation', value: 'volume_search' },
|
||||||
|
mode: 'advanced',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'langRestrict',
|
||||||
|
title: 'Language',
|
||||||
|
type: 'short-input',
|
||||||
|
placeholder: 'ISO 639-1 code (e.g., en, es, fr)',
|
||||||
|
condition: { field: 'operation', value: 'volume_search' },
|
||||||
|
mode: 'advanced',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'volumeId',
|
||||||
|
title: 'Volume ID',
|
||||||
|
type: 'short-input',
|
||||||
|
placeholder: 'Google Books volume ID',
|
||||||
|
condition: { field: 'operation', value: 'volume_details' },
|
||||||
|
required: { field: 'operation', value: 'volume_details' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'projection',
|
||||||
|
title: 'Projection',
|
||||||
|
type: 'dropdown',
|
||||||
|
options: [
|
||||||
|
{ label: 'Full', id: 'full' },
|
||||||
|
{ label: 'Lite', id: 'lite' },
|
||||||
|
],
|
||||||
|
value: () => 'full',
|
||||||
|
condition: { field: 'operation', value: 'volume_details' },
|
||||||
|
mode: 'advanced',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
tools: {
|
||||||
|
access: ['google_books_volume_search', 'google_books_volume_details'],
|
||||||
|
config: {
|
||||||
|
tool: (params) => `google_books_${params.operation}`,
|
||||||
|
params: (params) => {
|
||||||
|
const { operation, ...rest } = params
|
||||||
|
|
||||||
|
let maxResults: number | undefined
|
||||||
|
if (params.maxResults) {
|
||||||
|
maxResults = Number.parseInt(params.maxResults, 10)
|
||||||
|
if (Number.isNaN(maxResults)) {
|
||||||
|
maxResults = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let startIndex: number | undefined
|
||||||
|
if (params.startIndex) {
|
||||||
|
startIndex = Number.parseInt(params.startIndex, 10)
|
||||||
|
if (Number.isNaN(startIndex)) {
|
||||||
|
startIndex = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
maxResults,
|
||||||
|
startIndex,
|
||||||
|
filter: params.filter || undefined,
|
||||||
|
printType: params.printType || undefined,
|
||||||
|
orderBy: params.orderBy || undefined,
|
||||||
|
projection: params.projection || undefined,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
inputs: {
|
||||||
|
operation: { type: 'string', description: 'Operation to perform' },
|
||||||
|
apiKey: { type: 'string', description: 'Google Books API key' },
|
||||||
|
query: { type: 'string', description: 'Search query' },
|
||||||
|
filter: { type: 'string', description: 'Filter by availability' },
|
||||||
|
printType: { type: 'string', description: 'Print type filter' },
|
||||||
|
orderBy: { type: 'string', description: 'Sort order' },
|
||||||
|
maxResults: { type: 'string', description: 'Maximum number of results' },
|
||||||
|
startIndex: { type: 'string', description: 'Starting index for pagination' },
|
||||||
|
langRestrict: { type: 'string', description: 'Language restriction' },
|
||||||
|
volumeId: { type: 'string', description: 'Volume ID for details' },
|
||||||
|
projection: { type: 'string', description: 'Projection level' },
|
||||||
|
},
|
||||||
|
|
||||||
|
outputs: {
|
||||||
|
totalItems: { type: 'number', description: 'Total number of matching results' },
|
||||||
|
volumes: { type: 'json', description: 'List of matching volumes' },
|
||||||
|
id: { type: 'string', description: 'Volume ID' },
|
||||||
|
title: { type: 'string', description: 'Book title' },
|
||||||
|
subtitle: { type: 'string', description: 'Book subtitle' },
|
||||||
|
authors: { type: 'json', description: 'List of authors' },
|
||||||
|
publisher: { type: 'string', description: 'Publisher name' },
|
||||||
|
publishedDate: { type: 'string', description: 'Publication date' },
|
||||||
|
description: { type: 'string', description: 'Book description' },
|
||||||
|
pageCount: { type: 'number', description: 'Number of pages' },
|
||||||
|
categories: { type: 'json', description: 'Book categories' },
|
||||||
|
averageRating: { type: 'number', description: 'Average rating (1-5)' },
|
||||||
|
ratingsCount: { type: 'number', description: 'Number of ratings' },
|
||||||
|
language: { type: 'string', description: 'Language code' },
|
||||||
|
previewLink: { type: 'string', description: 'Link to preview on Google Books' },
|
||||||
|
infoLink: { type: 'string', description: 'Link to info page' },
|
||||||
|
thumbnailUrl: { type: 'string', description: 'Book cover thumbnail URL' },
|
||||||
|
isbn10: { type: 'string', description: 'ISBN-10 identifier' },
|
||||||
|
isbn13: { type: 'string', description: 'ISBN-13 identifier' },
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -93,12 +93,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
|||||||
'delete:issue-worklog:jira',
|
'delete:issue-worklog:jira',
|
||||||
'write:issue-link:jira',
|
'write:issue-link:jira',
|
||||||
'delete:issue-link:jira',
|
'delete:issue-link:jira',
|
||||||
'manage:jira-project',
|
|
||||||
'read:board-scope:jira-software',
|
|
||||||
'write:board-scope:jira-software',
|
|
||||||
'read:sprint:jira-software',
|
|
||||||
'write:sprint:jira-software',
|
|
||||||
'delete:sprint:jira-software',
|
|
||||||
],
|
],
|
||||||
placeholder: 'Select Jira account',
|
placeholder: 'Select Jira account',
|
||||||
},
|
},
|
||||||
@@ -701,32 +695,7 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
...getTrigger('jira_issue_updated').subBlocks,
|
...getTrigger('jira_issue_updated').subBlocks,
|
||||||
...getTrigger('jira_issue_deleted').subBlocks,
|
...getTrigger('jira_issue_deleted').subBlocks,
|
||||||
...getTrigger('jira_issue_commented').subBlocks,
|
...getTrigger('jira_issue_commented').subBlocks,
|
||||||
...getTrigger('jira_comment_updated').subBlocks,
|
|
||||||
...getTrigger('jira_comment_deleted').subBlocks,
|
|
||||||
...getTrigger('jira_worklog_created').subBlocks,
|
...getTrigger('jira_worklog_created').subBlocks,
|
||||||
...getTrigger('jira_worklog_updated').subBlocks,
|
|
||||||
...getTrigger('jira_worklog_deleted').subBlocks,
|
|
||||||
...getTrigger('jira_sprint_created').subBlocks,
|
|
||||||
...getTrigger('jira_sprint_started').subBlocks,
|
|
||||||
...getTrigger('jira_sprint_closed').subBlocks,
|
|
||||||
...getTrigger('jira_sprint_updated').subBlocks,
|
|
||||||
...getTrigger('jira_sprint_deleted').subBlocks,
|
|
||||||
...getTrigger('jira_project_created').subBlocks,
|
|
||||||
...getTrigger('jira_project_updated').subBlocks,
|
|
||||||
...getTrigger('jira_project_deleted').subBlocks,
|
|
||||||
...getTrigger('jira_version_created').subBlocks,
|
|
||||||
...getTrigger('jira_version_released').subBlocks,
|
|
||||||
...getTrigger('jira_version_unreleased').subBlocks,
|
|
||||||
...getTrigger('jira_version_updated').subBlocks,
|
|
||||||
...getTrigger('jira_version_deleted').subBlocks,
|
|
||||||
...getTrigger('jira_board_created').subBlocks,
|
|
||||||
...getTrigger('jira_board_updated').subBlocks,
|
|
||||||
...getTrigger('jira_board_deleted').subBlocks,
|
|
||||||
...getTrigger('jira_board_config_changed').subBlocks,
|
|
||||||
...getTrigger('jira_attachment_created').subBlocks,
|
|
||||||
...getTrigger('jira_attachment_deleted').subBlocks,
|
|
||||||
...getTrigger('jira_issuelink_created').subBlocks,
|
|
||||||
...getTrigger('jira_issuelink_deleted').subBlocks,
|
|
||||||
...getTrigger('jira_webhook').subBlocks,
|
...getTrigger('jira_webhook').subBlocks,
|
||||||
],
|
],
|
||||||
tools: {
|
tools: {
|
||||||
@@ -1271,32 +1240,7 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
'jira_issue_updated',
|
'jira_issue_updated',
|
||||||
'jira_issue_deleted',
|
'jira_issue_deleted',
|
||||||
'jira_issue_commented',
|
'jira_issue_commented',
|
||||||
'jira_comment_updated',
|
|
||||||
'jira_comment_deleted',
|
|
||||||
'jira_worklog_created',
|
'jira_worklog_created',
|
||||||
'jira_worklog_updated',
|
|
||||||
'jira_worklog_deleted',
|
|
||||||
'jira_sprint_created',
|
|
||||||
'jira_sprint_started',
|
|
||||||
'jira_sprint_closed',
|
|
||||||
'jira_sprint_updated',
|
|
||||||
'jira_sprint_deleted',
|
|
||||||
'jira_project_created',
|
|
||||||
'jira_project_updated',
|
|
||||||
'jira_project_deleted',
|
|
||||||
'jira_version_created',
|
|
||||||
'jira_version_released',
|
|
||||||
'jira_version_unreleased',
|
|
||||||
'jira_version_updated',
|
|
||||||
'jira_version_deleted',
|
|
||||||
'jira_board_created',
|
|
||||||
'jira_board_updated',
|
|
||||||
'jira_board_deleted',
|
|
||||||
'jira_board_config_changed',
|
|
||||||
'jira_attachment_created',
|
|
||||||
'jira_attachment_deleted',
|
|
||||||
'jira_issuelink_created',
|
|
||||||
'jira_issuelink_deleted',
|
|
||||||
'jira_webhook',
|
'jira_webhook',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ import { JiraServiceManagementIcon } from '@/components/icons'
|
|||||||
import type { BlockConfig } from '@/blocks/types'
|
import type { BlockConfig } from '@/blocks/types'
|
||||||
import { AuthMode } from '@/blocks/types'
|
import { AuthMode } from '@/blocks/types'
|
||||||
import type { JsmResponse } from '@/tools/jsm/types'
|
import type { JsmResponse } from '@/tools/jsm/types'
|
||||||
import { getTrigger } from '@/triggers'
|
|
||||||
|
|
||||||
export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
||||||
type: 'jira_service_management',
|
type: 'jira_service_management',
|
||||||
name: 'Jira Service Management',
|
name: 'Jira Service Management',
|
||||||
description: 'Interact with Jira Service Management',
|
description: 'Interact with Jira Service Management',
|
||||||
authMode: AuthMode.OAuth,
|
authMode: AuthMode.OAuth,
|
||||||
triggerAllowed: true,
|
|
||||||
longDescription:
|
longDescription:
|
||||||
'Integrate with Jira Service Management for IT service management. Create and manage service requests, handle customers and organizations, track SLAs, and manage queues. Can also trigger workflows based on Jira Service Management webhook events.',
|
'Integrate with Jira Service Management for IT service management. Create and manage service requests, handle customers and organizations, track SLAs, and manage queues.',
|
||||||
docsLink: 'https://docs.sim.ai/tools/jira-service-management',
|
docsLink: 'https://docs.sim.ai/tools/jira-service-management',
|
||||||
category: 'tools',
|
category: 'tools',
|
||||||
bgColor: '#E0E0E0',
|
bgColor: '#E0E0E0',
|
||||||
@@ -23,46 +21,26 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
|||||||
type: 'dropdown',
|
type: 'dropdown',
|
||||||
options: [
|
options: [
|
||||||
{ label: 'Get Service Desks', id: 'get_service_desks' },
|
{ label: 'Get Service Desks', id: 'get_service_desks' },
|
||||||
{ label: 'Get Service Desk', id: 'get_service_desk' },
|
|
||||||
{ label: 'Get Request Types', id: 'get_request_types' },
|
{ label: 'Get Request Types', id: 'get_request_types' },
|
||||||
{ label: 'Get Request Type Fields', id: 'get_request_type_fields' },
|
|
||||||
{ label: 'Create Request', id: 'create_request' },
|
{ label: 'Create Request', id: 'create_request' },
|
||||||
{ label: 'Get Request', id: 'get_request' },
|
{ label: 'Get Request', id: 'get_request' },
|
||||||
{ label: 'Get Requests', id: 'get_requests' },
|
{ label: 'Get Requests', id: 'get_requests' },
|
||||||
{ label: 'Get Request Status', id: 'get_request_status' },
|
|
||||||
{ label: 'Get Request Attachments', id: 'get_request_attachments' },
|
|
||||||
{ label: 'Add Comment', id: 'add_comment' },
|
{ label: 'Add Comment', id: 'add_comment' },
|
||||||
{ label: 'Get Comments', id: 'get_comments' },
|
{ label: 'Get Comments', id: 'get_comments' },
|
||||||
{ label: 'Get Customers', id: 'get_customers' },
|
{ label: 'Get Customers', id: 'get_customers' },
|
||||||
{ label: 'Add Customer', id: 'add_customer' },
|
{ label: 'Add Customer', id: 'add_customer' },
|
||||||
{ label: 'Remove Customer', id: 'remove_customer' },
|
|
||||||
{ label: 'Create Customer', id: 'create_customer' },
|
|
||||||
{ label: 'Get Organizations', id: 'get_organizations' },
|
{ label: 'Get Organizations', id: 'get_organizations' },
|
||||||
{ label: 'Get Organization', id: 'get_organization' },
|
|
||||||
{ label: 'Create Organization', id: 'create_organization' },
|
{ label: 'Create Organization', id: 'create_organization' },
|
||||||
{ label: 'Add Organization', id: 'add_organization' },
|
{ label: 'Add Organization', id: 'add_organization' },
|
||||||
{ label: 'Remove Organization', id: 'remove_organization' },
|
|
||||||
{ label: 'Delete Organization', id: 'delete_organization' },
|
|
||||||
{ label: 'Get Organization Users', id: 'get_organization_users' },
|
|
||||||
{ label: 'Add Organization Users', id: 'add_organization_users' },
|
|
||||||
{ label: 'Remove Organization Users', id: 'remove_organization_users' },
|
|
||||||
{ label: 'Get Queues', id: 'get_queues' },
|
{ label: 'Get Queues', id: 'get_queues' },
|
||||||
{ label: 'Get Queue Issues', id: 'get_queue_issues' },
|
|
||||||
{ label: 'Get SLA', id: 'get_sla' },
|
{ label: 'Get SLA', id: 'get_sla' },
|
||||||
{ label: 'Get Transitions', id: 'get_transitions' },
|
{ label: 'Get Transitions', id: 'get_transitions' },
|
||||||
{ label: 'Transition Request', id: 'transition_request' },
|
{ label: 'Transition Request', id: 'transition_request' },
|
||||||
{ label: 'Get Participants', id: 'get_participants' },
|
{ label: 'Get Participants', id: 'get_participants' },
|
||||||
{ label: 'Add Participants', id: 'add_participants' },
|
{ label: 'Add Participants', id: 'add_participants' },
|
||||||
{ label: 'Remove Participants', id: 'remove_participants' },
|
|
||||||
{ label: 'Get Approvals', id: 'get_approvals' },
|
{ label: 'Get Approvals', id: 'get_approvals' },
|
||||||
{ label: 'Answer Approval', id: 'answer_approval' },
|
{ label: 'Answer Approval', id: 'answer_approval' },
|
||||||
{ label: 'Get Feedback', id: 'get_feedback' },
|
{ label: 'Get Request Type Fields', id: 'get_request_type_fields' },
|
||||||
{ label: 'Add Feedback', id: 'add_feedback' },
|
|
||||||
{ label: 'Delete Feedback', id: 'delete_feedback' },
|
|
||||||
{ label: 'Get Notification', id: 'get_notification' },
|
|
||||||
{ label: 'Subscribe Notification', id: 'subscribe_notification' },
|
|
||||||
{ label: 'Unsubscribe Notification', id: 'unsubscribe_notification' },
|
|
||||||
{ label: 'Search Knowledge Base', id: 'search_knowledge_base' },
|
|
||||||
],
|
],
|
||||||
value: () => 'get_service_desks',
|
value: () => 'get_service_desks',
|
||||||
},
|
},
|
||||||
@@ -114,18 +92,6 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
|||||||
'write:request.participant:jira-service-management',
|
'write:request.participant:jira-service-management',
|
||||||
'read:request.approval:jira-service-management',
|
'read:request.approval:jira-service-management',
|
||||||
'write:request.approval:jira-service-management',
|
'write:request.approval:jira-service-management',
|
||||||
'read:request.feedback:jira-service-management',
|
|
||||||
'write:request.feedback:jira-service-management',
|
|
||||||
'delete:request.feedback:jira-service-management',
|
|
||||||
'read:request.notification:jira-service-management',
|
|
||||||
'write:request.notification:jira-service-management',
|
|
||||||
'delete:request.notification:jira-service-management',
|
|
||||||
'read:request.attachment:jira-service-management',
|
|
||||||
'read:knowledgebase:jira-service-management',
|
|
||||||
'read:organization.user:jira-service-management',
|
|
||||||
'write:organization.user:jira-service-management',
|
|
||||||
'delete:organization:jira-service-management',
|
|
||||||
'delete:servicedesk.customer:jira-service-management',
|
|
||||||
],
|
],
|
||||||
placeholder: 'Select Jira account',
|
placeholder: 'Select Jira account',
|
||||||
},
|
},
|
||||||
@@ -137,20 +103,15 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
|||||||
condition: {
|
condition: {
|
||||||
field: 'operation',
|
field: 'operation',
|
||||||
value: [
|
value: [
|
||||||
'get_service_desk',
|
|
||||||
'get_request_types',
|
'get_request_types',
|
||||||
'create_request',
|
'create_request',
|
||||||
'get_customers',
|
'get_customers',
|
||||||
'add_customer',
|
'add_customer',
|
||||||
'remove_customer',
|
|
||||||
'get_organizations',
|
'get_organizations',
|
||||||
'add_organization',
|
'add_organization',
|
||||||
'remove_organization',
|
|
||||||
'get_queues',
|
'get_queues',
|
||||||
'get_queue_issues',
|
|
||||||
'get_requests',
|
'get_requests',
|
||||||
'get_request_type_fields',
|
'get_request_type_fields',
|
||||||
'search_knowledge_base',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -172,8 +133,6 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
|||||||
field: 'operation',
|
field: 'operation',
|
||||||
value: [
|
value: [
|
||||||
'get_request',
|
'get_request',
|
||||||
'get_request_status',
|
|
||||||
'get_request_attachments',
|
|
||||||
'add_comment',
|
'add_comment',
|
||||||
'get_comments',
|
'get_comments',
|
||||||
'get_sla',
|
'get_sla',
|
||||||
@@ -181,15 +140,8 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
|||||||
'transition_request',
|
'transition_request',
|
||||||
'get_participants',
|
'get_participants',
|
||||||
'add_participants',
|
'add_participants',
|
||||||
'remove_participants',
|
|
||||||
'get_approvals',
|
'get_approvals',
|
||||||
'answer_approval',
|
'answer_approval',
|
||||||
'get_feedback',
|
|
||||||
'add_feedback',
|
|
||||||
'delete_feedback',
|
|
||||||
'get_notification',
|
|
||||||
'subscribe_notification',
|
|
||||||
'unsubscribe_notification',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -321,15 +273,7 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: 'Comma-separated Atlassian account IDs',
|
placeholder: 'Comma-separated Atlassian account IDs',
|
||||||
condition: {
|
condition: { field: 'operation', value: 'add_customer' },
|
||||||
field: 'operation',
|
|
||||||
value: [
|
|
||||||
'add_customer',
|
|
||||||
'remove_customer',
|
|
||||||
'add_organization_users',
|
|
||||||
'remove_organization_users',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'customerQuery',
|
id: 'customerQuery',
|
||||||
@@ -422,18 +366,7 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: 'Enter organization ID',
|
placeholder: 'Enter organization ID',
|
||||||
condition: {
|
condition: { field: 'operation', value: 'add_organization' },
|
||||||
field: 'operation',
|
|
||||||
value: [
|
|
||||||
'add_organization',
|
|
||||||
'remove_organization',
|
|
||||||
'delete_organization',
|
|
||||||
'get_organization',
|
|
||||||
'get_organization_users',
|
|
||||||
'add_organization_users',
|
|
||||||
'remove_organization_users',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'participantAccountIds',
|
id: 'participantAccountIds',
|
||||||
@@ -441,7 +374,7 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
type: 'short-input',
|
type: 'short-input',
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: 'Comma-separated account IDs',
|
placeholder: 'Comma-separated account IDs',
|
||||||
condition: { field: 'operation', value: ['add_participants', 'remove_participants'] },
|
condition: { field: 'operation', value: 'add_participants' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'approvalId',
|
id: 'approvalId',
|
||||||
@@ -473,165 +406,55 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
'get_service_desks',
|
'get_service_desks',
|
||||||
'get_request_types',
|
'get_request_types',
|
||||||
'get_requests',
|
'get_requests',
|
||||||
'get_request_status',
|
|
||||||
'get_request_attachments',
|
|
||||||
'get_comments',
|
'get_comments',
|
||||||
'get_customers',
|
'get_customers',
|
||||||
'get_organizations',
|
'get_organizations',
|
||||||
'get_organization_users',
|
|
||||||
'get_queues',
|
'get_queues',
|
||||||
'get_queue_issues',
|
|
||||||
'get_sla',
|
'get_sla',
|
||||||
'get_transitions',
|
'get_transitions',
|
||||||
'get_participants',
|
'get_participants',
|
||||||
'get_approvals',
|
'get_approvals',
|
||||||
'search_knowledge_base',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'queueId',
|
|
||||||
title: 'Queue ID',
|
|
||||||
type: 'short-input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'Enter queue ID',
|
|
||||||
condition: { field: 'operation', value: 'get_queue_issues' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'customerEmail',
|
|
||||||
title: 'Customer Email',
|
|
||||||
type: 'short-input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'Enter customer email address',
|
|
||||||
condition: { field: 'operation', value: 'create_customer' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'customerDisplayName',
|
|
||||||
title: 'Display Name',
|
|
||||||
type: 'short-input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'Enter customer display name',
|
|
||||||
condition: { field: 'operation', value: 'create_customer' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'knowledgeBaseQuery',
|
|
||||||
title: 'Search Query',
|
|
||||||
type: 'short-input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'Search knowledge base articles',
|
|
||||||
condition: { field: 'operation', value: 'search_knowledge_base' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'feedbackRating',
|
|
||||||
title: 'Rating',
|
|
||||||
type: 'dropdown',
|
|
||||||
options: [
|
|
||||||
{ label: '1 - Very Unsatisfied', id: '1' },
|
|
||||||
{ label: '2 - Unsatisfied', id: '2' },
|
|
||||||
{ label: '3 - Neutral', id: '3' },
|
|
||||||
{ label: '4 - Satisfied', id: '4' },
|
|
||||||
{ label: '5 - Very Satisfied', id: '5' },
|
|
||||||
],
|
|
||||||
value: () => '5',
|
|
||||||
condition: { field: 'operation', value: 'add_feedback' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'feedbackComment',
|
|
||||||
title: 'Feedback Comment',
|
|
||||||
type: 'long-input',
|
|
||||||
placeholder: 'Optional feedback comment',
|
|
||||||
condition: { field: 'operation', value: 'add_feedback' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'includeAttachments',
|
|
||||||
title: 'Include File Content',
|
|
||||||
type: 'dropdown',
|
|
||||||
options: [
|
|
||||||
{ label: 'No', id: 'false' },
|
|
||||||
{ label: 'Yes', id: 'true' },
|
|
||||||
],
|
|
||||||
value: () => 'false',
|
|
||||||
condition: { field: 'operation', value: 'get_request_attachments' },
|
|
||||||
},
|
|
||||||
// Trigger SubBlocks
|
|
||||||
...getTrigger('jsm_request_created').subBlocks,
|
|
||||||
...getTrigger('jsm_request_updated').subBlocks,
|
|
||||||
...getTrigger('jsm_request_deleted').subBlocks,
|
|
||||||
...getTrigger('jsm_request_commented').subBlocks,
|
|
||||||
...getTrigger('jsm_comment_updated').subBlocks,
|
|
||||||
...getTrigger('jsm_comment_deleted').subBlocks,
|
|
||||||
...getTrigger('jsm_worklog_created').subBlocks,
|
|
||||||
...getTrigger('jsm_worklog_updated').subBlocks,
|
|
||||||
...getTrigger('jsm_worklog_deleted').subBlocks,
|
|
||||||
...getTrigger('jsm_attachment_created').subBlocks,
|
|
||||||
...getTrigger('jsm_attachment_deleted').subBlocks,
|
|
||||||
...getTrigger('jsm_webhook').subBlocks,
|
|
||||||
],
|
],
|
||||||
tools: {
|
tools: {
|
||||||
access: [
|
access: [
|
||||||
'jsm_get_service_desks',
|
'jsm_get_service_desks',
|
||||||
'jsm_get_service_desk',
|
|
||||||
'jsm_get_request_types',
|
'jsm_get_request_types',
|
||||||
'jsm_get_request_type_fields',
|
|
||||||
'jsm_create_request',
|
'jsm_create_request',
|
||||||
'jsm_get_request',
|
'jsm_get_request',
|
||||||
'jsm_get_requests',
|
'jsm_get_requests',
|
||||||
'jsm_get_request_status',
|
|
||||||
'jsm_get_request_attachments',
|
|
||||||
'jsm_add_comment',
|
'jsm_add_comment',
|
||||||
'jsm_get_comments',
|
'jsm_get_comments',
|
||||||
'jsm_get_customers',
|
'jsm_get_customers',
|
||||||
'jsm_add_customer',
|
'jsm_add_customer',
|
||||||
'jsm_remove_customer',
|
|
||||||
'jsm_create_customer',
|
|
||||||
'jsm_get_organizations',
|
'jsm_get_organizations',
|
||||||
'jsm_get_organization',
|
|
||||||
'jsm_create_organization',
|
'jsm_create_organization',
|
||||||
'jsm_add_organization',
|
'jsm_add_organization',
|
||||||
'jsm_remove_organization',
|
|
||||||
'jsm_delete_organization',
|
|
||||||
'jsm_get_organization_users',
|
|
||||||
'jsm_add_organization_users',
|
|
||||||
'jsm_remove_organization_users',
|
|
||||||
'jsm_get_queues',
|
'jsm_get_queues',
|
||||||
'jsm_get_queue_issues',
|
|
||||||
'jsm_get_sla',
|
'jsm_get_sla',
|
||||||
'jsm_get_transitions',
|
'jsm_get_transitions',
|
||||||
'jsm_transition_request',
|
'jsm_transition_request',
|
||||||
'jsm_get_participants',
|
'jsm_get_participants',
|
||||||
'jsm_add_participants',
|
'jsm_add_participants',
|
||||||
'jsm_remove_participants',
|
|
||||||
'jsm_get_approvals',
|
'jsm_get_approvals',
|
||||||
'jsm_answer_approval',
|
'jsm_answer_approval',
|
||||||
'jsm_get_feedback',
|
'jsm_get_request_type_fields',
|
||||||
'jsm_add_feedback',
|
|
||||||
'jsm_delete_feedback',
|
|
||||||
'jsm_get_notification',
|
|
||||||
'jsm_subscribe_notification',
|
|
||||||
'jsm_unsubscribe_notification',
|
|
||||||
'jsm_search_knowledge_base',
|
|
||||||
],
|
],
|
||||||
config: {
|
config: {
|
||||||
tool: (params) => {
|
tool: (params) => {
|
||||||
switch (params.operation) {
|
switch (params.operation) {
|
||||||
case 'get_service_desks':
|
case 'get_service_desks':
|
||||||
return 'jsm_get_service_desks'
|
return 'jsm_get_service_desks'
|
||||||
case 'get_service_desk':
|
|
||||||
return 'jsm_get_service_desk'
|
|
||||||
case 'get_request_types':
|
case 'get_request_types':
|
||||||
return 'jsm_get_request_types'
|
return 'jsm_get_request_types'
|
||||||
case 'get_request_type_fields':
|
|
||||||
return 'jsm_get_request_type_fields'
|
|
||||||
case 'create_request':
|
case 'create_request':
|
||||||
return 'jsm_create_request'
|
return 'jsm_create_request'
|
||||||
case 'get_request':
|
case 'get_request':
|
||||||
return 'jsm_get_request'
|
return 'jsm_get_request'
|
||||||
case 'get_requests':
|
case 'get_requests':
|
||||||
return 'jsm_get_requests'
|
return 'jsm_get_requests'
|
||||||
case 'get_request_status':
|
|
||||||
return 'jsm_get_request_status'
|
|
||||||
case 'get_request_attachments':
|
|
||||||
return 'jsm_get_request_attachments'
|
|
||||||
case 'add_comment':
|
case 'add_comment':
|
||||||
return 'jsm_add_comment'
|
return 'jsm_add_comment'
|
||||||
case 'get_comments':
|
case 'get_comments':
|
||||||
@@ -640,32 +463,14 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
return 'jsm_get_customers'
|
return 'jsm_get_customers'
|
||||||
case 'add_customer':
|
case 'add_customer':
|
||||||
return 'jsm_add_customer'
|
return 'jsm_add_customer'
|
||||||
case 'remove_customer':
|
|
||||||
return 'jsm_remove_customer'
|
|
||||||
case 'create_customer':
|
|
||||||
return 'jsm_create_customer'
|
|
||||||
case 'get_organizations':
|
case 'get_organizations':
|
||||||
return 'jsm_get_organizations'
|
return 'jsm_get_organizations'
|
||||||
case 'get_organization':
|
|
||||||
return 'jsm_get_organization'
|
|
||||||
case 'create_organization':
|
case 'create_organization':
|
||||||
return 'jsm_create_organization'
|
return 'jsm_create_organization'
|
||||||
case 'add_organization':
|
case 'add_organization':
|
||||||
return 'jsm_add_organization'
|
return 'jsm_add_organization'
|
||||||
case 'remove_organization':
|
|
||||||
return 'jsm_remove_organization'
|
|
||||||
case 'delete_organization':
|
|
||||||
return 'jsm_delete_organization'
|
|
||||||
case 'get_organization_users':
|
|
||||||
return 'jsm_get_organization_users'
|
|
||||||
case 'add_organization_users':
|
|
||||||
return 'jsm_add_organization_users'
|
|
||||||
case 'remove_organization_users':
|
|
||||||
return 'jsm_remove_organization_users'
|
|
||||||
case 'get_queues':
|
case 'get_queues':
|
||||||
return 'jsm_get_queues'
|
return 'jsm_get_queues'
|
||||||
case 'get_queue_issues':
|
|
||||||
return 'jsm_get_queue_issues'
|
|
||||||
case 'get_sla':
|
case 'get_sla':
|
||||||
return 'jsm_get_sla'
|
return 'jsm_get_sla'
|
||||||
case 'get_transitions':
|
case 'get_transitions':
|
||||||
@@ -676,26 +481,12 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
return 'jsm_get_participants'
|
return 'jsm_get_participants'
|
||||||
case 'add_participants':
|
case 'add_participants':
|
||||||
return 'jsm_add_participants'
|
return 'jsm_add_participants'
|
||||||
case 'remove_participants':
|
|
||||||
return 'jsm_remove_participants'
|
|
||||||
case 'get_approvals':
|
case 'get_approvals':
|
||||||
return 'jsm_get_approvals'
|
return 'jsm_get_approvals'
|
||||||
case 'answer_approval':
|
case 'answer_approval':
|
||||||
return 'jsm_answer_approval'
|
return 'jsm_answer_approval'
|
||||||
case 'get_feedback':
|
case 'get_request_type_fields':
|
||||||
return 'jsm_get_feedback'
|
return 'jsm_get_request_type_fields'
|
||||||
case 'add_feedback':
|
|
||||||
return 'jsm_add_feedback'
|
|
||||||
case 'delete_feedback':
|
|
||||||
return 'jsm_delete_feedback'
|
|
||||||
case 'get_notification':
|
|
||||||
return 'jsm_get_notification'
|
|
||||||
case 'subscribe_notification':
|
|
||||||
return 'jsm_subscribe_notification'
|
|
||||||
case 'unsubscribe_notification':
|
|
||||||
return 'jsm_unsubscribe_notification'
|
|
||||||
case 'search_knowledge_base':
|
|
||||||
return 'jsm_search_knowledge_base'
|
|
||||||
default:
|
default:
|
||||||
return 'jsm_get_service_desks'
|
return 'jsm_get_service_desks'
|
||||||
}
|
}
|
||||||
@@ -940,204 +731,6 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
serviceDeskId: params.serviceDeskId,
|
serviceDeskId: params.serviceDeskId,
|
||||||
requestTypeId: params.requestTypeId,
|
requestTypeId: params.requestTypeId,
|
||||||
}
|
}
|
||||||
case 'get_service_desk':
|
|
||||||
if (!params.serviceDeskId) {
|
|
||||||
throw new Error('Service Desk ID is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
serviceDeskId: params.serviceDeskId,
|
|
||||||
}
|
|
||||||
case 'get_request_status':
|
|
||||||
if (!params.issueIdOrKey) {
|
|
||||||
throw new Error('Issue ID or key is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
issueIdOrKey: params.issueIdOrKey,
|
|
||||||
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
|
|
||||||
}
|
|
||||||
case 'get_request_attachments':
|
|
||||||
if (!params.issueIdOrKey) {
|
|
||||||
throw new Error('Issue ID or key is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
issueIdOrKey: params.issueIdOrKey,
|
|
||||||
includeAttachments: params.includeAttachments === 'true',
|
|
||||||
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
|
|
||||||
}
|
|
||||||
case 'remove_customer': {
|
|
||||||
if (!params.serviceDeskId) {
|
|
||||||
throw new Error('Service Desk ID is required')
|
|
||||||
}
|
|
||||||
if (!params.accountIds) {
|
|
||||||
throw new Error('Account IDs are required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
serviceDeskId: params.serviceDeskId,
|
|
||||||
accountIds: params.accountIds,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'create_customer':
|
|
||||||
if (!params.customerEmail) {
|
|
||||||
throw new Error('Customer email is required')
|
|
||||||
}
|
|
||||||
if (!params.customerDisplayName) {
|
|
||||||
throw new Error('Customer display name is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
email: params.customerEmail,
|
|
||||||
displayName: params.customerDisplayName,
|
|
||||||
}
|
|
||||||
case 'get_organization':
|
|
||||||
if (!params.organizationId) {
|
|
||||||
throw new Error('Organization ID is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
organizationId: params.organizationId,
|
|
||||||
}
|
|
||||||
case 'remove_organization':
|
|
||||||
if (!params.serviceDeskId) {
|
|
||||||
throw new Error('Service Desk ID is required')
|
|
||||||
}
|
|
||||||
if (!params.organizationId) {
|
|
||||||
throw new Error('Organization ID is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
serviceDeskId: params.serviceDeskId,
|
|
||||||
organizationId: params.organizationId,
|
|
||||||
}
|
|
||||||
case 'delete_organization':
|
|
||||||
if (!params.organizationId) {
|
|
||||||
throw new Error('Organization ID is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
organizationId: params.organizationId,
|
|
||||||
}
|
|
||||||
case 'get_organization_users':
|
|
||||||
if (!params.organizationId) {
|
|
||||||
throw new Error('Organization ID is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
organizationId: params.organizationId,
|
|
||||||
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
|
|
||||||
}
|
|
||||||
case 'add_organization_users':
|
|
||||||
if (!params.organizationId) {
|
|
||||||
throw new Error('Organization ID is required')
|
|
||||||
}
|
|
||||||
if (!params.accountIds) {
|
|
||||||
throw new Error('Account IDs are required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
organizationId: params.organizationId,
|
|
||||||
accountIds: params.accountIds,
|
|
||||||
}
|
|
||||||
case 'remove_organization_users':
|
|
||||||
if (!params.organizationId) {
|
|
||||||
throw new Error('Organization ID is required')
|
|
||||||
}
|
|
||||||
if (!params.accountIds) {
|
|
||||||
throw new Error('Account IDs are required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
organizationId: params.organizationId,
|
|
||||||
accountIds: params.accountIds,
|
|
||||||
}
|
|
||||||
case 'get_queue_issues':
|
|
||||||
if (!params.serviceDeskId) {
|
|
||||||
throw new Error('Service Desk ID is required')
|
|
||||||
}
|
|
||||||
if (!params.queueId) {
|
|
||||||
throw new Error('Queue ID is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
serviceDeskId: params.serviceDeskId,
|
|
||||||
queueId: params.queueId,
|
|
||||||
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
|
|
||||||
}
|
|
||||||
case 'remove_participants':
|
|
||||||
if (!params.issueIdOrKey) {
|
|
||||||
throw new Error('Issue ID or key is required')
|
|
||||||
}
|
|
||||||
if (!params.participantAccountIds) {
|
|
||||||
throw new Error('Account IDs are required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
issueIdOrKey: params.issueIdOrKey,
|
|
||||||
accountIds: params.participantAccountIds,
|
|
||||||
}
|
|
||||||
case 'get_feedback':
|
|
||||||
if (!params.issueIdOrKey) {
|
|
||||||
throw new Error('Issue ID or key is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
issueIdOrKey: params.issueIdOrKey,
|
|
||||||
}
|
|
||||||
case 'add_feedback':
|
|
||||||
if (!params.issueIdOrKey) {
|
|
||||||
throw new Error('Issue ID or key is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
issueIdOrKey: params.issueIdOrKey,
|
|
||||||
rating: Number.parseInt(params.feedbackRating || '5'),
|
|
||||||
comment: params.feedbackComment,
|
|
||||||
}
|
|
||||||
case 'delete_feedback':
|
|
||||||
if (!params.issueIdOrKey) {
|
|
||||||
throw new Error('Issue ID or key is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
issueIdOrKey: params.issueIdOrKey,
|
|
||||||
}
|
|
||||||
case 'get_notification':
|
|
||||||
if (!params.issueIdOrKey) {
|
|
||||||
throw new Error('Issue ID or key is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
issueIdOrKey: params.issueIdOrKey,
|
|
||||||
}
|
|
||||||
case 'subscribe_notification':
|
|
||||||
if (!params.issueIdOrKey) {
|
|
||||||
throw new Error('Issue ID or key is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
issueIdOrKey: params.issueIdOrKey,
|
|
||||||
}
|
|
||||||
case 'unsubscribe_notification':
|
|
||||||
if (!params.issueIdOrKey) {
|
|
||||||
throw new Error('Issue ID or key is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
issueIdOrKey: params.issueIdOrKey,
|
|
||||||
}
|
|
||||||
case 'search_knowledge_base':
|
|
||||||
if (!params.knowledgeBaseQuery) {
|
|
||||||
throw new Error('Search query is required')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
serviceDeskId: params.serviceDeskId,
|
|
||||||
query: params.knowledgeBaseQuery,
|
|
||||||
limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined,
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return baseParams
|
return baseParams
|
||||||
}
|
}
|
||||||
@@ -1186,16 +779,6 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
searchQuery: { type: 'string', description: 'Filter request types by name' },
|
searchQuery: { type: 'string', description: 'Filter request types by name' },
|
||||||
groupId: { type: 'string', description: 'Filter by request type group ID' },
|
groupId: { type: 'string', description: 'Filter by request type group ID' },
|
||||||
expand: { type: 'string', description: 'Comma-separated fields to expand' },
|
expand: { type: 'string', description: 'Comma-separated fields to expand' },
|
||||||
queueId: { type: 'string', description: 'Queue ID' },
|
|
||||||
customerEmail: { type: 'string', description: 'Customer email address' },
|
|
||||||
customerDisplayName: { type: 'string', description: 'Customer display name' },
|
|
||||||
knowledgeBaseQuery: { type: 'string', description: 'Knowledge base search query' },
|
|
||||||
feedbackRating: { type: 'string', description: 'CSAT feedback rating (1-5)' },
|
|
||||||
feedbackComment: { type: 'string', description: 'CSAT feedback comment' },
|
|
||||||
includeAttachments: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Whether to download attachment file content',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
outputs: {
|
outputs: {
|
||||||
ts: { type: 'string', description: 'Timestamp of the operation' },
|
ts: { type: 'string', description: 'Timestamp of the operation' },
|
||||||
@@ -1227,19 +810,6 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
total: { type: 'number', description: 'Total count' },
|
total: { type: 'number', description: 'Total count' },
|
||||||
isLastPage: { type: 'boolean', description: 'Whether this is the last page' },
|
isLastPage: { type: 'boolean', description: 'Whether this is the last page' },
|
||||||
requestTypeFields: { type: 'json', description: 'Array of request type fields' },
|
requestTypeFields: { type: 'json', description: 'Array of request type fields' },
|
||||||
rating: { type: 'number', description: 'CSAT feedback rating' },
|
|
||||||
subscribed: { type: 'boolean', description: 'Whether subscribed to notifications' },
|
|
||||||
articles: { type: 'json', description: 'Array of knowledge base articles' },
|
|
||||||
statuses: { type: 'json', description: 'Array of request status history entries' },
|
|
||||||
attachments: { type: 'json', description: 'Array of attachment metadata' },
|
|
||||||
issues: { type: 'json', description: 'Array of queue issues' },
|
|
||||||
users: { type: 'json', description: 'Array of organization users' },
|
|
||||||
id: { type: 'string', description: 'Resource ID' },
|
|
||||||
projectId: { type: 'string', description: 'Service desk project ID' },
|
|
||||||
projectName: { type: 'string', description: 'Service desk project name' },
|
|
||||||
projectKey: { type: 'string', description: 'Service desk project key' },
|
|
||||||
email: { type: 'string', description: 'Customer email address' },
|
|
||||||
displayName: { type: 'string', description: 'Customer display name' },
|
|
||||||
canAddRequestParticipants: {
|
canAddRequestParticipants: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Whether participants can be added to this request type',
|
description: 'Whether participants can be added to this request type',
|
||||||
@@ -1248,36 +818,5 @@ Return ONLY the comment text - no explanations.`,
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Whether requests can be raised on behalf of another user',
|
description: 'Whether requests can be raised on behalf of another user',
|
||||||
},
|
},
|
||||||
// Trigger outputs (from webhook events)
|
|
||||||
webhookEvent: { type: 'string', description: 'Webhook event type' },
|
|
||||||
issue: { type: 'json', description: 'Complete issue object from webhook' },
|
|
||||||
changelog: { type: 'json', description: 'Changelog object (for update events)' },
|
|
||||||
comment: { type: 'json', description: 'Comment object (for comment events)' },
|
|
||||||
worklog: { type: 'json', description: 'Worklog object (for worklog events)' },
|
|
||||||
attachment: { type: 'json', description: 'Attachment metadata (for attachment events)' },
|
|
||||||
files: {
|
|
||||||
type: 'file[]',
|
|
||||||
description:
|
|
||||||
'Downloaded file attachments (if includeFiles is enabled and Jira credentials are provided)',
|
|
||||||
},
|
|
||||||
user: { type: 'json', description: 'User object who triggered the event' },
|
|
||||||
webhook: { type: 'json', description: 'Complete webhook payload' },
|
|
||||||
},
|
|
||||||
triggers: {
|
|
||||||
enabled: true,
|
|
||||||
available: [
|
|
||||||
'jsm_request_created',
|
|
||||||
'jsm_request_updated',
|
|
||||||
'jsm_request_deleted',
|
|
||||||
'jsm_request_commented',
|
|
||||||
'jsm_comment_updated',
|
|
||||||
'jsm_comment_deleted',
|
|
||||||
'jsm_worklog_created',
|
|
||||||
'jsm_worklog_updated',
|
|
||||||
'jsm_worklog_deleted',
|
|
||||||
'jsm_attachment_created',
|
|
||||||
'jsm_attachment_deleted',
|
|
||||||
'jsm_webhook',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ export const S3Block: BlockConfig<S3Response> = {
|
|||||||
},
|
},
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'getObjectRegion',
|
||||||
|
title: 'AWS Region',
|
||||||
|
type: 'short-input',
|
||||||
|
placeholder: 'Used when S3 URL does not include region',
|
||||||
|
condition: {
|
||||||
|
field: 'operation',
|
||||||
|
value: ['get_object'],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'bucketName',
|
id: 'bucketName',
|
||||||
title: 'Bucket Name',
|
title: 'Bucket Name',
|
||||||
@@ -291,34 +301,11 @@ export const S3Block: BlockConfig<S3Response> = {
|
|||||||
if (!params.s3Uri) {
|
if (!params.s3Uri) {
|
||||||
throw new Error('S3 Object URL is required')
|
throw new Error('S3 Object URL is required')
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
// Parse S3 URI for get_object
|
accessKeyId: params.accessKeyId,
|
||||||
try {
|
secretAccessKey: params.secretAccessKey,
|
||||||
const url = new URL(params.s3Uri)
|
region: params.getObjectRegion || params.region,
|
||||||
const hostname = url.hostname
|
s3Uri: params.s3Uri,
|
||||||
const bucketName = hostname.split('.')[0]
|
|
||||||
const regionMatch = hostname.match(/s3[.-]([^.]+)\.amazonaws\.com/)
|
|
||||||
const region = regionMatch ? regionMatch[1] : params.region
|
|
||||||
const objectKey = url.pathname.startsWith('/')
|
|
||||||
? url.pathname.substring(1)
|
|
||||||
: url.pathname
|
|
||||||
|
|
||||||
if (!bucketName || !objectKey) {
|
|
||||||
throw new Error('Could not parse S3 URL')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessKeyId: params.accessKeyId,
|
|
||||||
secretAccessKey: params.secretAccessKey,
|
|
||||||
region,
|
|
||||||
bucketName,
|
|
||||||
objectKey,
|
|
||||||
s3Uri: params.s3Uri,
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
throw new Error(
|
|
||||||
'Invalid S3 Object URL format. Expected: https://bucket-name.s3.region.amazonaws.com/path/to/file'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +388,7 @@ export const S3Block: BlockConfig<S3Response> = {
|
|||||||
acl: { type: 'string', description: 'Access control list' },
|
acl: { type: 'string', description: 'Access control list' },
|
||||||
// Download inputs
|
// Download inputs
|
||||||
s3Uri: { type: 'string', description: 'S3 object URL' },
|
s3Uri: { type: 'string', description: 'S3 object URL' },
|
||||||
|
getObjectRegion: { type: 'string', description: 'Optional AWS region override for downloads' },
|
||||||
// List inputs
|
// List inputs
|
||||||
prefix: { type: 'string', description: 'Prefix filter' },
|
prefix: { type: 'string', description: 'Prefix filter' },
|
||||||
maxKeys: { type: 'number', description: 'Maximum results' },
|
maxKeys: { type: 'number', description: 'Maximum results' },
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { GitHubBlock, GitHubV2Block } from '@/blocks/blocks/github'
|
|||||||
import { GitLabBlock } from '@/blocks/blocks/gitlab'
|
import { GitLabBlock } from '@/blocks/blocks/gitlab'
|
||||||
import { GmailBlock, GmailV2Block } from '@/blocks/blocks/gmail'
|
import { GmailBlock, GmailV2Block } from '@/blocks/blocks/gmail'
|
||||||
import { GoogleSearchBlock } from '@/blocks/blocks/google'
|
import { GoogleSearchBlock } from '@/blocks/blocks/google'
|
||||||
|
import { GoogleBooksBlock } from '@/blocks/blocks/google_books'
|
||||||
import { GoogleCalendarBlock, GoogleCalendarV2Block } from '@/blocks/blocks/google_calendar'
|
import { GoogleCalendarBlock, GoogleCalendarV2Block } from '@/blocks/blocks/google_calendar'
|
||||||
import { GoogleDocsBlock } from '@/blocks/blocks/google_docs'
|
import { GoogleDocsBlock } from '@/blocks/blocks/google_docs'
|
||||||
import { GoogleDriveBlock } from '@/blocks/blocks/google_drive'
|
import { GoogleDriveBlock } from '@/blocks/blocks/google_drive'
|
||||||
@@ -214,6 +215,7 @@ export const registry: Record<string, BlockConfig> = {
|
|||||||
gmail_v2: GmailV2Block,
|
gmail_v2: GmailV2Block,
|
||||||
google_calendar: GoogleCalendarBlock,
|
google_calendar: GoogleCalendarBlock,
|
||||||
google_calendar_v2: GoogleCalendarV2Block,
|
google_calendar_v2: GoogleCalendarV2Block,
|
||||||
|
google_books: GoogleBooksBlock,
|
||||||
google_docs: GoogleDocsBlock,
|
google_docs: GoogleDocsBlock,
|
||||||
google_drive: GoogleDriveBlock,
|
google_drive: GoogleDriveBlock,
|
||||||
google_forms: GoogleFormsBlock,
|
google_forms: GoogleFormsBlock,
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ export interface SubBlockConfig {
|
|||||||
type: SubBlockType
|
type: SubBlockType
|
||||||
mode?: 'basic' | 'advanced' | 'both' | 'trigger' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode
|
mode?: 'basic' | 'advanced' | 'both' | 'trigger' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode
|
||||||
canonicalParamId?: string
|
canonicalParamId?: string
|
||||||
|
/** Controls parameter visibility in agent/tool-input context */
|
||||||
|
paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden'
|
||||||
required?:
|
required?:
|
||||||
| boolean
|
| boolean
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -1157,6 +1157,21 @@ export function AirweaveIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GoogleBooksIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 478.633 540.068'>
|
||||||
|
<path
|
||||||
|
fill='#1C51A4'
|
||||||
|
d='M449.059,218.231L245.519,99.538l-0.061,193.23c0.031,1.504-0.368,2.977-1.166,4.204c-0.798,1.258-1.565,1.995-2.915,2.547c-1.35,0.552-2.792,0.706-4.204,0.399c-1.412-0.307-2.7-1.043-3.713-2.117l-69.166-70.609l-69.381,70.179c-1.013,0.982-2.301,1.657-3.652,1.903c-1.381,0.246-2.792,0.092-4.081-0.491c-1.289-0.583-1.626-0.522-2.394-1.749c-0.767-1.197-1.197-2.608-1.197-4.081L85.031,6.007l-2.915-1.289C43.973-11.638,0,16.409,0,59.891v420.306c0,46.029,49.312,74.782,88.775,51.767l360.285-210.138C488.491,298.782,488.491,241.246,449.059,218.231z'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='#80D7FB'
|
||||||
|
d='M88.805,8.124c-2.179-1.289-4.419-2.363-6.659-3.345l0.123,288.663c0,1.442,0.43,2.854,1.197,4.081c0.767,1.197,1.872,2.148,3.161,2.731c1.289,0.583,2.7,0.736,4.081,0.491c1.381-0.246,2.639-0.921,3.652-1.903l69.749-69.688l69.811,69.749c1.013,1.074,2.301,1.81,3.713,2.117c1.412,0.307,2.884,0.153,4.204-0.399c1.319-0.552,2.455-1.565,3.253-2.792c0.798-1.258,1.197-2.731,1.166-4.204V99.998L88.805,8.124z'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function GoogleDocsIcon(props: SVGProps<SVGSVGElement>) {
|
export function GoogleDocsIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { setupGlobalFetchMock } from '@sim/testing'
|
||||||
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||||
import { getAllBlocks } from '@/blocks'
|
import { getAllBlocks } from '@/blocks'
|
||||||
import { BlockType, isMcpTool } from '@/executor/constants'
|
import { BlockType, isMcpTool } from '@/executor/constants'
|
||||||
@@ -61,6 +62,30 @@ vi.mock('@/providers', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/executor/utils/http', () => ({
|
||||||
|
buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }),
|
||||||
|
buildAPIUrl: vi.fn((path: string, params?: Record<string, string>) => {
|
||||||
|
const url = new URL(path, 'http://localhost:3000')
|
||||||
|
if (params) {
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
url.searchParams.set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}),
|
||||||
|
extractAPIErrorMessage: vi.fn(async (response: Response) => {
|
||||||
|
const defaultMessage = `API request failed with status ${response.status}`
|
||||||
|
try {
|
||||||
|
const errorData = await response.json()
|
||||||
|
return errorData.error || defaultMessage
|
||||||
|
} catch {
|
||||||
|
return defaultMessage
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@sim/db', () => ({
|
vi.mock('@sim/db', () => ({
|
||||||
db: {
|
db: {
|
||||||
select: vi.fn().mockReturnValue({
|
select: vi.fn().mockReturnValue({
|
||||||
@@ -84,7 +109,7 @@ vi.mock('@sim/db/schema', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
global.fetch = Object.assign(vi.fn(), { preconnect: vi.fn() }) as typeof fetch
|
setupGlobalFetchMock()
|
||||||
|
|
||||||
const mockGetAllBlocks = getAllBlocks as Mock
|
const mockGetAllBlocks = getAllBlocks as Mock
|
||||||
const mockExecuteTool = executeTool as Mock
|
const mockExecuteTool = executeTool as Mock
|
||||||
@@ -1901,5 +1926,301 @@ describe('AgentBlockHandler', () => {
|
|||||||
|
|
||||||
expect(discoveryCalls[0].url).toContain('serverId=mcp-legacy-server')
|
expect(discoveryCalls[0].url).toContain('serverId=mcp-legacy-server')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('customToolId resolution - DB as source of truth', () => {
|
||||||
|
const staleInlineSchema = {
|
||||||
|
function: {
|
||||||
|
name: 'formatReport',
|
||||||
|
description: 'Formats a report',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', description: 'Report title' },
|
||||||
|
content: { type: 'string', description: 'Report content' },
|
||||||
|
},
|
||||||
|
required: ['title', 'content'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbSchema = {
|
||||||
|
function: {
|
||||||
|
name: 'formatReport',
|
||||||
|
description: 'Formats a report',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', description: 'Report title' },
|
||||||
|
content: { type: 'string', description: 'Report content' },
|
||||||
|
format: { type: 'string', description: 'Output format' },
|
||||||
|
},
|
||||||
|
required: ['title', 'content', 'format'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const staleInlineCode = 'return { title, content };'
|
||||||
|
const dbCode = 'return { title, content, format };'
|
||||||
|
|
||||||
|
function mockFetchForCustomTool(toolId: string) {
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (typeof url === 'string' && url.includes('/api/tools/custom')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
headers: { get: () => null },
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: toolId,
|
||||||
|
title: 'formatReport',
|
||||||
|
schema: dbSchema,
|
||||||
|
code: dbCode,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
headers: { get: () => null },
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetchFailure() {
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (typeof url === 'string' && url.includes('/api/tools/custom')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
headers: { get: () => null },
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
headers: { get: () => null },
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(global, 'window', {
|
||||||
|
value: undefined,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should always fetch latest schema from DB when customToolId is present', async () => {
|
||||||
|
const toolId = 'custom-tool-123'
|
||||||
|
mockFetchForCustomTool(toolId)
|
||||||
|
|
||||||
|
const inputs = {
|
||||||
|
model: 'gpt-4o',
|
||||||
|
userPrompt: 'Format a report',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: 'custom-tool',
|
||||||
|
customToolId: toolId,
|
||||||
|
title: 'formatReport',
|
||||||
|
schema: staleInlineSchema,
|
||||||
|
code: staleInlineCode,
|
||||||
|
usageControl: 'auto' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGetProviderFromModel.mockReturnValue('openai')
|
||||||
|
|
||||||
|
await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect(mockExecuteProviderRequest).toHaveBeenCalled()
|
||||||
|
const providerCall = mockExecuteProviderRequest.mock.calls[0]
|
||||||
|
const tools = providerCall[1].tools
|
||||||
|
|
||||||
|
expect(tools.length).toBe(1)
|
||||||
|
// DB schema wins over stale inline — includes format param
|
||||||
|
expect(tools[0].parameters.required).toContain('format')
|
||||||
|
expect(tools[0].parameters.properties).toHaveProperty('format')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fetch from DB when customToolId has no inline schema', async () => {
|
||||||
|
const toolId = 'custom-tool-123'
|
||||||
|
mockFetchForCustomTool(toolId)
|
||||||
|
|
||||||
|
const inputs = {
|
||||||
|
model: 'gpt-4o',
|
||||||
|
userPrompt: 'Format a report',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: 'custom-tool',
|
||||||
|
customToolId: toolId,
|
||||||
|
usageControl: 'auto' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGetProviderFromModel.mockReturnValue('openai')
|
||||||
|
|
||||||
|
await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect(mockExecuteProviderRequest).toHaveBeenCalled()
|
||||||
|
const providerCall = mockExecuteProviderRequest.mock.calls[0]
|
||||||
|
const tools = providerCall[1].tools
|
||||||
|
|
||||||
|
expect(tools.length).toBe(1)
|
||||||
|
expect(tools[0].name).toBe('formatReport')
|
||||||
|
expect(tools[0].parameters.required).toContain('format')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to inline schema when DB fetch fails and inline exists', async () => {
|
||||||
|
mockFetchFailure()
|
||||||
|
|
||||||
|
const inputs = {
|
||||||
|
model: 'gpt-4o',
|
||||||
|
userPrompt: 'Format a report',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: 'custom-tool',
|
||||||
|
customToolId: 'custom-tool-123',
|
||||||
|
title: 'formatReport',
|
||||||
|
schema: staleInlineSchema,
|
||||||
|
code: staleInlineCode,
|
||||||
|
usageControl: 'auto' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGetProviderFromModel.mockReturnValue('openai')
|
||||||
|
|
||||||
|
await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect(mockExecuteProviderRequest).toHaveBeenCalled()
|
||||||
|
const providerCall = mockExecuteProviderRequest.mock.calls[0]
|
||||||
|
const tools = providerCall[1].tools
|
||||||
|
|
||||||
|
expect(tools.length).toBe(1)
|
||||||
|
expect(tools[0].name).toBe('formatReport')
|
||||||
|
expect(tools[0].parameters.required).not.toContain('format')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null when DB fetch fails and no inline schema exists', async () => {
|
||||||
|
mockFetchFailure()
|
||||||
|
|
||||||
|
const inputs = {
|
||||||
|
model: 'gpt-4o',
|
||||||
|
userPrompt: 'Format a report',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: 'custom-tool',
|
||||||
|
customToolId: 'custom-tool-123',
|
||||||
|
usageControl: 'auto' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGetProviderFromModel.mockReturnValue('openai')
|
||||||
|
|
||||||
|
await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect(mockExecuteProviderRequest).toHaveBeenCalled()
|
||||||
|
const providerCall = mockExecuteProviderRequest.mock.calls[0]
|
||||||
|
const tools = providerCall[1].tools
|
||||||
|
|
||||||
|
expect(tools.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use DB code for executeFunction when customToolId resolves', async () => {
|
||||||
|
const toolId = 'custom-tool-123'
|
||||||
|
mockFetchForCustomTool(toolId)
|
||||||
|
|
||||||
|
let capturedTools: any[] = []
|
||||||
|
Promise.all = vi.fn().mockImplementation((promises: Promise<any>[]) => {
|
||||||
|
const result = originalPromiseAll.call(Promise, promises)
|
||||||
|
result.then((tools: any[]) => {
|
||||||
|
if (tools?.length) {
|
||||||
|
capturedTools = tools.filter((t) => t !== null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputs = {
|
||||||
|
model: 'gpt-4o',
|
||||||
|
userPrompt: 'Format a report',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: 'custom-tool',
|
||||||
|
customToolId: toolId,
|
||||||
|
title: 'formatReport',
|
||||||
|
schema: staleInlineSchema,
|
||||||
|
code: staleInlineCode,
|
||||||
|
usageControl: 'auto' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGetProviderFromModel.mockReturnValue('openai')
|
||||||
|
|
||||||
|
await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect(capturedTools.length).toBe(1)
|
||||||
|
expect(typeof capturedTools[0].executeFunction).toBe('function')
|
||||||
|
|
||||||
|
await capturedTools[0].executeFunction({ title: 'Q1', format: 'pdf' })
|
||||||
|
|
||||||
|
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||||
|
'function_execute',
|
||||||
|
expect.objectContaining({
|
||||||
|
code: dbCode,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
expect.any(Object)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not fetch from DB when no customToolId is present', async () => {
|
||||||
|
const inputs = {
|
||||||
|
model: 'gpt-4o',
|
||||||
|
userPrompt: 'Use the tool',
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: 'custom-tool',
|
||||||
|
title: 'formatReport',
|
||||||
|
schema: staleInlineSchema,
|
||||||
|
code: staleInlineCode,
|
||||||
|
usageControl: 'auto' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGetProviderFromModel.mockReturnValue('openai')
|
||||||
|
|
||||||
|
await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
const customToolFetches = mockFetch.mock.calls.filter(
|
||||||
|
(call: any[]) => typeof call[0] === 'string' && call[0].includes('/api/tools/custom')
|
||||||
|
)
|
||||||
|
expect(customToolFetches.length).toBe(0)
|
||||||
|
|
||||||
|
expect(mockExecuteProviderRequest).toHaveBeenCalled()
|
||||||
|
const providerCall = mockExecuteProviderRequest.mock.calls[0]
|
||||||
|
const tools = providerCall[1].tools
|
||||||
|
|
||||||
|
expect(tools.length).toBe(1)
|
||||||
|
expect(tools[0].name).toBe('formatReport')
|
||||||
|
expect(tools[0].parameters.required).not.toContain('format')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,9 +62,12 @@ export class AgentBlockHandler implements BlockHandler {
|
|||||||
await validateModelProvider(ctx.userId, model, ctx)
|
await validateModelProvider(ctx.userId, model, ctx)
|
||||||
|
|
||||||
const providerId = getProviderFromModel(model)
|
const providerId = getProviderFromModel(model)
|
||||||
const formattedTools = await this.formatTools(ctx, filteredInputs.tools || [])
|
const formattedTools = await this.formatTools(
|
||||||
|
ctx,
|
||||||
|
filteredInputs.tools || [],
|
||||||
|
block.canonicalModes
|
||||||
|
)
|
||||||
|
|
||||||
// Resolve skill metadata for progressive disclosure
|
|
||||||
const skillInputs = filteredInputs.skills ?? []
|
const skillInputs = filteredInputs.skills ?? []
|
||||||
let skillMetadata: Array<{ name: string; description: string }> = []
|
let skillMetadata: Array<{ name: string; description: string }> = []
|
||||||
if (skillInputs.length > 0 && ctx.workspaceId) {
|
if (skillInputs.length > 0 && ctx.workspaceId) {
|
||||||
@@ -221,7 +224,11 @@ export class AgentBlockHandler implements BlockHandler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async formatTools(ctx: ExecutionContext, inputTools: ToolInput[]): Promise<any[]> {
|
private async formatTools(
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
inputTools: ToolInput[],
|
||||||
|
canonicalModes?: Record<string, 'basic' | 'advanced'>
|
||||||
|
): Promise<any[]> {
|
||||||
if (!Array.isArray(inputTools)) return []
|
if (!Array.isArray(inputTools)) return []
|
||||||
|
|
||||||
const filtered = inputTools.filter((tool) => {
|
const filtered = inputTools.filter((tool) => {
|
||||||
@@ -249,7 +256,7 @@ export class AgentBlockHandler implements BlockHandler {
|
|||||||
if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) {
|
if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) {
|
||||||
return await this.createCustomTool(ctx, tool)
|
return await this.createCustomTool(ctx, tool)
|
||||||
}
|
}
|
||||||
return this.transformBlockTool(ctx, tool)
|
return this.transformBlockTool(ctx, tool, canonicalModes)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[AgentHandler] Error creating tool:`, { tool, error })
|
logger.error(`[AgentHandler] Error creating tool:`, { tool, error })
|
||||||
return null
|
return null
|
||||||
@@ -272,15 +279,16 @@ export class AgentBlockHandler implements BlockHandler {
|
|||||||
let code = tool.code
|
let code = tool.code
|
||||||
let title = tool.title
|
let title = tool.title
|
||||||
|
|
||||||
if (tool.customToolId && !schema) {
|
if (tool.customToolId) {
|
||||||
const resolved = await this.fetchCustomToolById(ctx, tool.customToolId)
|
const resolved = await this.fetchCustomToolById(ctx, tool.customToolId)
|
||||||
if (!resolved) {
|
if (resolved) {
|
||||||
|
schema = resolved.schema
|
||||||
|
code = resolved.code
|
||||||
|
title = resolved.title
|
||||||
|
} else if (!schema) {
|
||||||
logger.error(`Custom tool not found: ${tool.customToolId}`)
|
logger.error(`Custom tool not found: ${tool.customToolId}`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
schema = resolved.schema
|
|
||||||
code = resolved.code
|
|
||||||
title = resolved.title
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!schema?.function) {
|
if (!schema?.function) {
|
||||||
@@ -719,12 +727,17 @@ export class AgentBlockHandler implements BlockHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async transformBlockTool(ctx: ExecutionContext, tool: ToolInput) {
|
private async transformBlockTool(
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
tool: ToolInput,
|
||||||
|
canonicalModes?: Record<string, 'basic' | 'advanced'>
|
||||||
|
) {
|
||||||
const transformedTool = await transformBlockTool(tool, {
|
const transformedTool = await transformBlockTool(tool, {
|
||||||
selectedOperation: tool.operation,
|
selectedOperation: tool.operation,
|
||||||
getAllBlocks,
|
getAllBlocks,
|
||||||
getToolAsync: (toolId: string) => getToolAsync(toolId, ctx.workflowId),
|
getToolAsync: (toolId: string) => getToolAsync(toolId, ctx.workflowId),
|
||||||
getTool,
|
getTool,
|
||||||
|
canonicalModes,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (transformedTool) {
|
if (transformedTool) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
|
|||||||
import { account } from '@sim/db/schema'
|
import { account } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||||
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
|
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
|
||||||
import type { BlockOutput } from '@/blocks/types'
|
import type { BlockOutput } from '@/blocks/types'
|
||||||
@@ -79,7 +79,7 @@ export class RouterBlockHandler implements BlockHandler {
|
|||||||
const providerId = getProviderFromModel(routerConfig.model)
|
const providerId = getProviderFromModel(routerConfig.model)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL('/api/providers', getBaseUrl())
|
const url = new URL('/api/providers', getInternalApiBaseUrl())
|
||||||
if (ctx.userId) url.searchParams.set('userId', ctx.userId)
|
if (ctx.userId) url.searchParams.set('userId', ctx.userId)
|
||||||
|
|
||||||
const messages = [{ role: 'user', content: routerConfig.prompt }]
|
const messages = [{ role: 'user', content: routerConfig.prompt }]
|
||||||
@@ -209,7 +209,7 @@ export class RouterBlockHandler implements BlockHandler {
|
|||||||
const providerId = getProviderFromModel(routerConfig.model)
|
const providerId = getProviderFromModel(routerConfig.model)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL('/api/providers', getBaseUrl())
|
const url = new URL('/api/providers', getInternalApiBaseUrl())
|
||||||
if (ctx.userId) url.searchParams.set('userId', ctx.userId)
|
if (ctx.userId) url.searchParams.set('userId', ctx.userId)
|
||||||
|
|
||||||
const messages = [{ role: 'user', content: routerConfig.context }]
|
const messages = [{ role: 'user', content: routerConfig.context }]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { setupGlobalFetchMock } from '@sim/testing'
|
||||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||||
import { BlockType } from '@/executor/constants'
|
import { BlockType } from '@/executor/constants'
|
||||||
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
|
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
|
||||||
@@ -9,7 +10,7 @@ vi.mock('@/lib/auth/internal', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock fetch globally
|
// Mock fetch globally
|
||||||
global.fetch = vi.fn()
|
setupGlobalFetchMock()
|
||||||
|
|
||||||
describe('WorkflowBlockHandler', () => {
|
describe('WorkflowBlockHandler', () => {
|
||||||
let handler: WorkflowBlockHandler
|
let handler: WorkflowBlockHandler
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { generateInternalToken } from '@/lib/auth/internal'
|
import { generateInternalToken } from '@/lib/auth/internal'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { HTTP } from '@/executor/constants'
|
import { HTTP } from '@/executor/constants'
|
||||||
|
|
||||||
export async function buildAuthHeaders(): Promise<Record<string, string>> {
|
export async function buildAuthHeaders(): Promise<Record<string, string>> {
|
||||||
@@ -16,7 +16,8 @@ export async function buildAuthHeaders(): Promise<Record<string, string>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildAPIUrl(path: string, params?: Record<string, string>): URL {
|
export function buildAPIUrl(path: string, params?: Record<string, string>): URL {
|
||||||
const url = new URL(path, getBaseUrl())
|
const baseUrl = path.startsWith('/api/') ? getInternalApiBaseUrl() : getBaseUrl()
|
||||||
|
const url = new URL(path, baseUrl)
|
||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ interface GenerateVersionDescriptionVariables {
|
|||||||
|
|
||||||
const VERSION_DESCRIPTION_SYSTEM_PROMPT = `You are writing deployment version descriptions for a workflow automation platform.
|
const VERSION_DESCRIPTION_SYSTEM_PROMPT = `You are writing deployment version descriptions for a workflow automation platform.
|
||||||
|
|
||||||
Write a brief, factual description (1-3 sentences, under 400 characters) that states what changed between versions.
|
Write a brief, factual description (1-3 sentences, under 2000 characters) that states what changed between versions.
|
||||||
|
|
||||||
Guidelines:
|
Guidelines:
|
||||||
- Use the specific values provided (credential names, channel names, model names)
|
- Use the specific values provided (credential names, channel names, model names)
|
||||||
|
|||||||
@@ -642,6 +642,10 @@ export function useDeployChildWorkflow() {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: workflowKeys.deploymentStatus(variables.workflowId),
|
queryKey: workflowKeys.deploymentStatus(variables.workflowId),
|
||||||
})
|
})
|
||||||
|
// Invalidate workflow state so tool input mappings refresh
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: workflowKeys.state(variables.workflowId),
|
||||||
|
})
|
||||||
// Also invalidate deployment queries
|
// Also invalidate deployment queries
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: deploymentKeys.info(variables.workflowId),
|
queryKey: deploymentKeys.info(variables.workflowId),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useRef } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import type {
|
import type {
|
||||||
BlockCompletedData,
|
BlockCompletedData,
|
||||||
@@ -16,6 +16,18 @@ import type { SerializableExecutionState } from '@/executor/execution/types'
|
|||||||
|
|
||||||
const logger = createLogger('useExecutionStream')
|
const logger = createLogger('useExecutionStream')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects errors caused by the browser killing a fetch (page refresh, navigation, tab close).
|
||||||
|
* These should be treated as clean disconnects, not execution errors.
|
||||||
|
*/
|
||||||
|
function isClientDisconnectError(error: any): boolean {
|
||||||
|
if (error.name === 'AbortError') return true
|
||||||
|
const msg = (error.message ?? '').toLowerCase()
|
||||||
|
return (
|
||||||
|
msg.includes('network error') || msg.includes('failed to fetch') || msg.includes('load failed')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes SSE events from a response body and invokes appropriate callbacks.
|
* Processes SSE events from a response body and invokes appropriate callbacks.
|
||||||
*/
|
*/
|
||||||
@@ -121,6 +133,7 @@ export interface ExecuteStreamOptions {
|
|||||||
parallels?: Record<string, any>
|
parallels?: Record<string, any>
|
||||||
}
|
}
|
||||||
stopAfterBlockId?: string
|
stopAfterBlockId?: string
|
||||||
|
onExecutionId?: (executionId: string) => void
|
||||||
callbacks?: ExecutionStreamCallbacks
|
callbacks?: ExecutionStreamCallbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,30 +142,40 @@ export interface ExecuteFromBlockOptions {
|
|||||||
startBlockId: string
|
startBlockId: string
|
||||||
sourceSnapshot: SerializableExecutionState
|
sourceSnapshot: SerializableExecutionState
|
||||||
input?: any
|
input?: any
|
||||||
|
onExecutionId?: (executionId: string) => void
|
||||||
callbacks?: ExecutionStreamCallbacks
|
callbacks?: ExecutionStreamCallbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReconnectStreamOptions {
|
||||||
|
workflowId: string
|
||||||
|
executionId: string
|
||||||
|
fromEventId?: number
|
||||||
|
callbacks?: ExecutionStreamCallbacks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level map shared across all hook instances.
|
||||||
|
* Ensures ANY instance can cancel streams started by ANY other instance,
|
||||||
|
* which is critical for SPA navigation where the original hook instance unmounts
|
||||||
|
* but the SSE stream must be cancellable from the new instance.
|
||||||
|
*/
|
||||||
|
const sharedAbortControllers = new Map<string, AbortController>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for executing workflows via server-side SSE streaming.
|
* Hook for executing workflows via server-side SSE streaming.
|
||||||
* Supports concurrent executions via per-workflow AbortController maps.
|
* Supports concurrent executions via per-workflow AbortController maps.
|
||||||
*/
|
*/
|
||||||
export function useExecutionStream() {
|
export function useExecutionStream() {
|
||||||
const abortControllersRef = useRef<Map<string, AbortController>>(new Map())
|
|
||||||
const currentExecutionsRef = useRef<Map<string, { workflowId: string; executionId: string }>>(
|
|
||||||
new Map()
|
|
||||||
)
|
|
||||||
|
|
||||||
const execute = useCallback(async (options: ExecuteStreamOptions) => {
|
const execute = useCallback(async (options: ExecuteStreamOptions) => {
|
||||||
const { workflowId, callbacks = {}, ...payload } = options
|
const { workflowId, callbacks = {}, onExecutionId, ...payload } = options
|
||||||
|
|
||||||
const existing = abortControllersRef.current.get(workflowId)
|
const existing = sharedAbortControllers.get(workflowId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.abort()
|
existing.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
abortControllersRef.current.set(workflowId, abortController)
|
sharedAbortControllers.set(workflowId, abortController)
|
||||||
currentExecutionsRef.current.delete(workflowId)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/workflows/${workflowId}/execute`, {
|
const response = await fetch(`/api/workflows/${workflowId}/execute`, {
|
||||||
@@ -177,42 +200,48 @@ export function useExecutionStream() {
|
|||||||
throw new Error('No response body')
|
throw new Error('No response body')
|
||||||
}
|
}
|
||||||
|
|
||||||
const executionId = response.headers.get('X-Execution-Id')
|
const serverExecutionId = response.headers.get('X-Execution-Id')
|
||||||
if (executionId) {
|
if (serverExecutionId) {
|
||||||
currentExecutionsRef.current.set(workflowId, { workflowId, executionId })
|
onExecutionId?.(serverExecutionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader()
|
const reader = response.body.getReader()
|
||||||
await processSSEStream(reader, callbacks, 'Execution')
|
await processSSEStream(reader, callbacks, 'Execution')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name === 'AbortError') {
|
if (isClientDisconnectError(error)) {
|
||||||
logger.info('Execution stream cancelled')
|
logger.info('Execution stream disconnected (page unload or abort)')
|
||||||
callbacks.onExecutionCancelled?.({ duration: 0 })
|
return
|
||||||
} else {
|
|
||||||
logger.error('Execution stream error:', error)
|
|
||||||
callbacks.onExecutionError?.({
|
|
||||||
error: error.message || 'Unknown error',
|
|
||||||
duration: 0,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
logger.error('Execution stream error:', error)
|
||||||
|
callbacks.onExecutionError?.({
|
||||||
|
error: error.message || 'Unknown error',
|
||||||
|
duration: 0,
|
||||||
|
})
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
abortControllersRef.current.delete(workflowId)
|
if (sharedAbortControllers.get(workflowId) === abortController) {
|
||||||
currentExecutionsRef.current.delete(workflowId)
|
sharedAbortControllers.delete(workflowId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const executeFromBlock = useCallback(async (options: ExecuteFromBlockOptions) => {
|
const executeFromBlock = useCallback(async (options: ExecuteFromBlockOptions) => {
|
||||||
const { workflowId, startBlockId, sourceSnapshot, input, callbacks = {} } = options
|
const {
|
||||||
|
workflowId,
|
||||||
|
startBlockId,
|
||||||
|
sourceSnapshot,
|
||||||
|
input,
|
||||||
|
onExecutionId,
|
||||||
|
callbacks = {},
|
||||||
|
} = options
|
||||||
|
|
||||||
const existing = abortControllersRef.current.get(workflowId)
|
const existing = sharedAbortControllers.get(workflowId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.abort()
|
existing.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
abortControllersRef.current.set(workflowId, abortController)
|
sharedAbortControllers.set(workflowId, abortController)
|
||||||
currentExecutionsRef.current.delete(workflowId)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/workflows/${workflowId}/execute`, {
|
const response = await fetch(`/api/workflows/${workflowId}/execute`, {
|
||||||
@@ -246,64 +275,80 @@ export function useExecutionStream() {
|
|||||||
throw new Error('No response body')
|
throw new Error('No response body')
|
||||||
}
|
}
|
||||||
|
|
||||||
const executionId = response.headers.get('X-Execution-Id')
|
const serverExecutionId = response.headers.get('X-Execution-Id')
|
||||||
if (executionId) {
|
if (serverExecutionId) {
|
||||||
currentExecutionsRef.current.set(workflowId, { workflowId, executionId })
|
onExecutionId?.(serverExecutionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader()
|
const reader = response.body.getReader()
|
||||||
await processSSEStream(reader, callbacks, 'Run-from-block')
|
await processSSEStream(reader, callbacks, 'Run-from-block')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name === 'AbortError') {
|
if (isClientDisconnectError(error)) {
|
||||||
logger.info('Run-from-block execution cancelled')
|
logger.info('Run-from-block stream disconnected (page unload or abort)')
|
||||||
callbacks.onExecutionCancelled?.({ duration: 0 })
|
return
|
||||||
} else {
|
|
||||||
logger.error('Run-from-block execution error:', error)
|
|
||||||
callbacks.onExecutionError?.({
|
|
||||||
error: error.message || 'Unknown error',
|
|
||||||
duration: 0,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
logger.error('Run-from-block execution error:', error)
|
||||||
|
callbacks.onExecutionError?.({
|
||||||
|
error: error.message || 'Unknown error',
|
||||||
|
duration: 0,
|
||||||
|
})
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
abortControllersRef.current.delete(workflowId)
|
if (sharedAbortControllers.get(workflowId) === abortController) {
|
||||||
currentExecutionsRef.current.delete(workflowId)
|
sharedAbortControllers.delete(workflowId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reconnect = useCallback(async (options: ReconnectStreamOptions) => {
|
||||||
|
const { workflowId, executionId, fromEventId = 0, callbacks = {} } = options
|
||||||
|
|
||||||
|
const existing = sharedAbortControllers.get(workflowId)
|
||||||
|
if (existing) {
|
||||||
|
existing.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
sharedAbortControllers.set(workflowId, abortController)
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/workflows/${workflowId}/executions/${executionId}/stream?from=${fromEventId}`,
|
||||||
|
{ signal: abortController.signal }
|
||||||
|
)
|
||||||
|
if (!response.ok) throw new Error(`Reconnect failed (${response.status})`)
|
||||||
|
if (!response.body) throw new Error('No response body')
|
||||||
|
|
||||||
|
await processSSEStream(response.body.getReader(), callbacks, 'Reconnect')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (isClientDisconnectError(error)) return
|
||||||
|
logger.error('Reconnection stream error:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
if (sharedAbortControllers.get(workflowId) === abortController) {
|
||||||
|
sharedAbortControllers.delete(workflowId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const cancel = useCallback((workflowId?: string) => {
|
const cancel = useCallback((workflowId?: string) => {
|
||||||
if (workflowId) {
|
if (workflowId) {
|
||||||
const execution = currentExecutionsRef.current.get(workflowId)
|
const controller = sharedAbortControllers.get(workflowId)
|
||||||
if (execution) {
|
|
||||||
fetch(`/api/workflows/${execution.workflowId}/executions/${execution.executionId}/cancel`, {
|
|
||||||
method: 'POST',
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = abortControllersRef.current.get(workflowId)
|
|
||||||
if (controller) {
|
if (controller) {
|
||||||
controller.abort()
|
controller.abort()
|
||||||
abortControllersRef.current.delete(workflowId)
|
sharedAbortControllers.delete(workflowId)
|
||||||
}
|
}
|
||||||
currentExecutionsRef.current.delete(workflowId)
|
|
||||||
} else {
|
} else {
|
||||||
for (const [, execution] of currentExecutionsRef.current) {
|
for (const [, controller] of sharedAbortControllers) {
|
||||||
fetch(`/api/workflows/${execution.workflowId}/executions/${execution.executionId}/cancel`, {
|
|
||||||
method: 'POST',
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [, controller] of abortControllersRef.current) {
|
|
||||||
controller.abort()
|
controller.abort()
|
||||||
}
|
}
|
||||||
abortControllersRef.current.clear()
|
sharedAbortControllers.clear()
|
||||||
currentExecutionsRef.current.clear()
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
execute,
|
execute,
|
||||||
executeFromBlock,
|
executeFromBlock,
|
||||||
|
reconnect,
|
||||||
cancel,
|
cancel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
apps/sim/hooks/use-referral-attribution.ts
Normal file
46
apps/sim/hooks/use-referral-attribution.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
|
||||||
|
const logger = createLogger('ReferralAttribution')
|
||||||
|
|
||||||
|
const COOKIE_NAME = 'sim_utm'
|
||||||
|
|
||||||
|
const TERMINAL_REASONS = new Set([
|
||||||
|
'invalid_cookie',
|
||||||
|
'no_utm_cookie',
|
||||||
|
'no_matching_campaign',
|
||||||
|
'already_attributed',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires a one-shot `POST /api/attribution` when a `sim_utm` cookie is present.
|
||||||
|
* Retries on transient failures; stops on terminal outcomes.
|
||||||
|
*/
|
||||||
|
export function useReferralAttribution() {
|
||||||
|
const calledRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (calledRef.current) return
|
||||||
|
if (!document.cookie.includes(COOKIE_NAME)) return
|
||||||
|
|
||||||
|
calledRef.current = true
|
||||||
|
|
||||||
|
fetch('/api/attribution', { method: 'POST' })
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.attributed) {
|
||||||
|
logger.info('Referral attribution successful', { bonusAmount: data.bonusAmount })
|
||||||
|
} else if (data.error || TERMINAL_REASONS.has(data.reason)) {
|
||||||
|
logger.info('Referral attribution skipped', { reason: data.reason || data.error })
|
||||||
|
} else {
|
||||||
|
calledRef.current = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.warn('Referral attribution failed, will retry', { error: err })
|
||||||
|
calledRef.current = false
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
64
apps/sim/lib/billing/credits/bonus.ts
Normal file
64
apps/sim/lib/billing/credits/bonus.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { db } from '@sim/db'
|
||||||
|
import { organization, userStats } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { eq, sql } from 'drizzle-orm'
|
||||||
|
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||||
|
import type { DbOrTx } from '@/lib/db/types'
|
||||||
|
|
||||||
|
const logger = createLogger('BonusCredits')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply bonus credits to a user (e.g. referral bonuses, promotional codes).
|
||||||
|
*
|
||||||
|
* Detects the user's current plan and routes credits accordingly:
|
||||||
|
* - Free/Pro: adds to `userStats.creditBalance` and increments `currentUsageLimit`
|
||||||
|
* - Team/Enterprise: adds to `organization.creditBalance` and increments `orgUsageLimit`
|
||||||
|
*
|
||||||
|
* Uses direct increment (not recalculation) so it works correctly for free-tier
|
||||||
|
* users where `setUsageLimitForCredits` would compute planBase=0 and skip the update.
|
||||||
|
*
|
||||||
|
* @param tx - Optional Drizzle transaction context. When provided, all DB writes
|
||||||
|
* participate in the caller's transaction for atomicity.
|
||||||
|
*/
|
||||||
|
export async function applyBonusCredits(
|
||||||
|
userId: string,
|
||||||
|
amount: number,
|
||||||
|
tx?: DbOrTx
|
||||||
|
): Promise<void> {
|
||||||
|
const dbCtx = tx ?? db
|
||||||
|
const subscription = await getHighestPrioritySubscription(userId)
|
||||||
|
const isTeamOrEnterprise = subscription?.plan === 'team' || subscription?.plan === 'enterprise'
|
||||||
|
|
||||||
|
if (isTeamOrEnterprise && subscription?.referenceId) {
|
||||||
|
const orgId = subscription.referenceId
|
||||||
|
|
||||||
|
await dbCtx
|
||||||
|
.update(organization)
|
||||||
|
.set({
|
||||||
|
creditBalance: sql`${organization.creditBalance} + ${amount}`,
|
||||||
|
orgUsageLimit: sql`COALESCE(${organization.orgUsageLimit}, '0')::decimal + ${amount}`,
|
||||||
|
})
|
||||||
|
.where(eq(organization.id, orgId))
|
||||||
|
|
||||||
|
logger.info('Applied bonus credits to organization', {
|
||||||
|
userId,
|
||||||
|
organizationId: orgId,
|
||||||
|
plan: subscription.plan,
|
||||||
|
amount,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await dbCtx
|
||||||
|
.update(userStats)
|
||||||
|
.set({
|
||||||
|
creditBalance: sql`${userStats.creditBalance} + ${amount}`,
|
||||||
|
currentUsageLimit: sql`COALESCE(${userStats.currentUsageLimit}, '0')::decimal + ${amount}`,
|
||||||
|
})
|
||||||
|
.where(eq(userStats.userId, userId))
|
||||||
|
|
||||||
|
logger.info('Applied bonus credits to user', {
|
||||||
|
userId,
|
||||||
|
plan: subscription?.plan || 'free',
|
||||||
|
amount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -220,6 +220,7 @@ export const env = createEnv({
|
|||||||
SOCKET_SERVER_URL: z.string().url().optional(), // WebSocket server URL for real-time features
|
SOCKET_SERVER_URL: z.string().url().optional(), // WebSocket server URL for real-time features
|
||||||
SOCKET_PORT: z.number().optional(), // Port for WebSocket server
|
SOCKET_PORT: z.number().optional(), // Port for WebSocket server
|
||||||
PORT: z.number().optional(), // Main application port
|
PORT: z.number().optional(), // Main application port
|
||||||
|
INTERNAL_API_BASE_URL: z.string().optional(), // Optional internal base URL for server-side self-calls; must include protocol if set (e.g., http://sim-app.namespace.svc.cluster.local:3000)
|
||||||
ALLOWED_ORIGINS: z.string().optional(), // CORS allowed origins
|
ALLOWED_ORIGINS: z.string().optional(), // CORS allowed origins
|
||||||
|
|
||||||
// OAuth Integration Credentials - All optional, enables third-party integrations
|
// OAuth Integration Credentials - All optional, enables third-party integrations
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import { getEnv } from '@/lib/core/config/env'
|
import { getEnv } from '@/lib/core/config/env'
|
||||||
import { isProd } from '@/lib/core/config/feature-flags'
|
import { isProd } from '@/lib/core/config/feature-flags'
|
||||||
|
|
||||||
|
function hasHttpProtocol(url: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(url: string): string {
|
||||||
|
if (hasHttpProtocol(url)) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = isProd ? 'https://' : 'http://'
|
||||||
|
return `${protocol}${url}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the base URL of the application from NEXT_PUBLIC_APP_URL
|
* Returns the base URL of the application from NEXT_PUBLIC_APP_URL
|
||||||
* This ensures webhooks, callbacks, and other integrations always use the correct public URL
|
* This ensures webhooks, callbacks, and other integrations always use the correct public URL
|
||||||
@@ -8,7 +21,7 @@ import { isProd } from '@/lib/core/config/feature-flags'
|
|||||||
* @throws Error if NEXT_PUBLIC_APP_URL is not configured
|
* @throws Error if NEXT_PUBLIC_APP_URL is not configured
|
||||||
*/
|
*/
|
||||||
export function getBaseUrl(): string {
|
export function getBaseUrl(): string {
|
||||||
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL')
|
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL')?.trim()
|
||||||
|
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -16,12 +29,26 @@ export function getBaseUrl(): string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (baseUrl.startsWith('http://') || baseUrl.startsWith('https://')) {
|
return normalizeBaseUrl(baseUrl)
|
||||||
return baseUrl
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the base URL used by server-side internal API calls.
|
||||||
|
* Falls back to NEXT_PUBLIC_APP_URL when INTERNAL_API_BASE_URL is not set.
|
||||||
|
*/
|
||||||
|
export function getInternalApiBaseUrl(): string {
|
||||||
|
const internalBaseUrl = getEnv('INTERNAL_API_BASE_URL')?.trim()
|
||||||
|
if (!internalBaseUrl) {
|
||||||
|
return getBaseUrl()
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocol = isProd ? 'https://' : 'http://'
|
if (!hasHttpProtocol(internalBaseUrl)) {
|
||||||
return `${protocol}${baseUrl}`
|
throw new Error(
|
||||||
|
'INTERNAL_API_BASE_URL must include protocol (http:// or https://), e.g. http://sim-app.default.svc.cluster.local:3000'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return internalBaseUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
246
apps/sim/lib/execution/event-buffer.ts
Normal file
246
apps/sim/lib/execution/event-buffer.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { getRedisClient } from '@/lib/core/config/redis'
|
||||||
|
import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events'
|
||||||
|
|
||||||
|
const logger = createLogger('ExecutionEventBuffer')
|
||||||
|
|
||||||
|
const REDIS_PREFIX = 'execution:stream:'
|
||||||
|
const TTL_SECONDS = 60 * 60 // 1 hour
|
||||||
|
const EVENT_LIMIT = 1000
|
||||||
|
const RESERVE_BATCH = 100
|
||||||
|
const FLUSH_INTERVAL_MS = 15
|
||||||
|
const FLUSH_MAX_BATCH = 200
|
||||||
|
|
||||||
|
function getEventsKey(executionId: string) {
|
||||||
|
return `${REDIS_PREFIX}${executionId}:events`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeqKey(executionId: string) {
|
||||||
|
return `${REDIS_PREFIX}${executionId}:seq`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetaKey(executionId: string) {
|
||||||
|
return `${REDIS_PREFIX}${executionId}:meta`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExecutionStreamStatus = 'active' | 'complete' | 'error' | 'cancelled'
|
||||||
|
|
||||||
|
export interface ExecutionStreamMeta {
|
||||||
|
status: ExecutionStreamStatus
|
||||||
|
userId?: string
|
||||||
|
workflowId?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionEventEntry {
|
||||||
|
eventId: number
|
||||||
|
executionId: string
|
||||||
|
event: ExecutionEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionEventWriter {
|
||||||
|
write: (event: ExecutionEvent) => Promise<ExecutionEventEntry>
|
||||||
|
flush: () => Promise<void>
|
||||||
|
close: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExecutionMeta(
|
||||||
|
executionId: string,
|
||||||
|
meta: Partial<ExecutionStreamMeta>
|
||||||
|
): Promise<void> {
|
||||||
|
const redis = getRedisClient()
|
||||||
|
if (!redis) {
|
||||||
|
logger.warn('setExecutionMeta: Redis client unavailable', { executionId })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const key = getMetaKey(executionId)
|
||||||
|
const payload: Record<string, string> = {
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
if (meta.status) payload.status = meta.status
|
||||||
|
if (meta.userId) payload.userId = meta.userId
|
||||||
|
if (meta.workflowId) payload.workflowId = meta.workflowId
|
||||||
|
await redis.hset(key, payload)
|
||||||
|
await redis.expire(key, TTL_SECONDS)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to update execution meta', {
|
||||||
|
executionId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExecutionMeta(executionId: string): Promise<ExecutionStreamMeta | null> {
|
||||||
|
const redis = getRedisClient()
|
||||||
|
if (!redis) {
|
||||||
|
logger.warn('getExecutionMeta: Redis client unavailable', { executionId })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const key = getMetaKey(executionId)
|
||||||
|
const meta = await redis.hgetall(key)
|
||||||
|
if (!meta || Object.keys(meta).length === 0) return null
|
||||||
|
return meta as unknown as ExecutionStreamMeta
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to read execution meta', {
|
||||||
|
executionId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readExecutionEvents(
|
||||||
|
executionId: string,
|
||||||
|
afterEventId: number
|
||||||
|
): Promise<ExecutionEventEntry[]> {
|
||||||
|
const redis = getRedisClient()
|
||||||
|
if (!redis) return []
|
||||||
|
try {
|
||||||
|
const raw = await redis.zrangebyscore(getEventsKey(executionId), afterEventId + 1, '+inf')
|
||||||
|
return raw
|
||||||
|
.map((entry) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(entry) as ExecutionEventEntry
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((entry): entry is ExecutionEventEntry => Boolean(entry))
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to read execution events', {
|
||||||
|
executionId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExecutionEventWriter(executionId: string): ExecutionEventWriter {
|
||||||
|
const redis = getRedisClient()
|
||||||
|
if (!redis) {
|
||||||
|
logger.warn(
|
||||||
|
'createExecutionEventWriter: Redis client unavailable, events will not be buffered',
|
||||||
|
{
|
||||||
|
executionId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
write: async (event) => ({ eventId: 0, executionId, event }),
|
||||||
|
flush: async () => {},
|
||||||
|
close: async () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending: ExecutionEventEntry[] = []
|
||||||
|
let nextEventId = 0
|
||||||
|
let maxReservedId = 0
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const scheduleFlush = () => {
|
||||||
|
if (flushTimer) return
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = null
|
||||||
|
void flush()
|
||||||
|
}, FLUSH_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reserveIds = async (minCount: number) => {
|
||||||
|
const reserveCount = Math.max(RESERVE_BATCH, minCount)
|
||||||
|
const newMax = await redis.incrby(getSeqKey(executionId), reserveCount)
|
||||||
|
const startId = newMax - reserveCount + 1
|
||||||
|
if (nextEventId === 0 || nextEventId > maxReservedId) {
|
||||||
|
nextEventId = startId
|
||||||
|
maxReservedId = newMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let flushPromise: Promise<void> | null = null
|
||||||
|
let closed = false
|
||||||
|
const inflightWrites = new Set<Promise<ExecutionEventEntry>>()
|
||||||
|
|
||||||
|
const doFlush = async () => {
|
||||||
|
if (pending.length === 0) return
|
||||||
|
const batch = pending
|
||||||
|
pending = []
|
||||||
|
try {
|
||||||
|
const key = getEventsKey(executionId)
|
||||||
|
const zaddArgs: (string | number)[] = []
|
||||||
|
for (const entry of batch) {
|
||||||
|
zaddArgs.push(entry.eventId, JSON.stringify(entry))
|
||||||
|
}
|
||||||
|
const pipeline = redis.pipeline()
|
||||||
|
pipeline.zadd(key, ...zaddArgs)
|
||||||
|
pipeline.expire(key, TTL_SECONDS)
|
||||||
|
pipeline.expire(getSeqKey(executionId), TTL_SECONDS)
|
||||||
|
pipeline.zremrangebyrank(key, 0, -EVENT_LIMIT - 1)
|
||||||
|
await pipeline.exec()
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to flush execution events', {
|
||||||
|
executionId,
|
||||||
|
batchSize: batch.length,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
})
|
||||||
|
pending = batch.concat(pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = async () => {
|
||||||
|
if (flushPromise) {
|
||||||
|
await flushPromise
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flushPromise = doFlush()
|
||||||
|
try {
|
||||||
|
await flushPromise
|
||||||
|
} finally {
|
||||||
|
flushPromise = null
|
||||||
|
if (pending.length > 0) scheduleFlush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeCore = async (event: ExecutionEvent): Promise<ExecutionEventEntry> => {
|
||||||
|
if (closed) return { eventId: 0, executionId, event }
|
||||||
|
if (nextEventId === 0 || nextEventId > maxReservedId) {
|
||||||
|
await reserveIds(1)
|
||||||
|
}
|
||||||
|
const eventId = nextEventId++
|
||||||
|
const entry: ExecutionEventEntry = { eventId, executionId, event }
|
||||||
|
pending.push(entry)
|
||||||
|
if (pending.length >= FLUSH_MAX_BATCH) {
|
||||||
|
await flush()
|
||||||
|
} else {
|
||||||
|
scheduleFlush()
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
const write = (event: ExecutionEvent): Promise<ExecutionEventEntry> => {
|
||||||
|
const p = writeCore(event)
|
||||||
|
inflightWrites.add(p)
|
||||||
|
const remove = () => inflightWrites.delete(p)
|
||||||
|
p.then(remove, remove)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = async () => {
|
||||||
|
closed = true
|
||||||
|
if (flushTimer) {
|
||||||
|
clearTimeout(flushTimer)
|
||||||
|
flushTimer = null
|
||||||
|
}
|
||||||
|
if (inflightWrites.size > 0) {
|
||||||
|
await Promise.allSettled(inflightWrites)
|
||||||
|
}
|
||||||
|
if (flushPromise) {
|
||||||
|
await flushPromise
|
||||||
|
}
|
||||||
|
if (pending.length > 0) {
|
||||||
|
await doFlush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { write, flush, close }
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
|
|||||||
import { account } from '@sim/db/schema'
|
import { account } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||||
import { executeProviderRequest } from '@/providers'
|
import { executeProviderRequest } from '@/providers'
|
||||||
import { getProviderFromModel } from '@/providers/utils'
|
import { getProviderFromModel } from '@/providers/utils'
|
||||||
@@ -61,7 +61,7 @@ async function queryKnowledgeBase(
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Call the knowledge base search API directly
|
// Call the knowledge base search API directly
|
||||||
const searchUrl = `${getBaseUrl()}/api/knowledge/search`
|
const searchUrl = `${getInternalApiBaseUrl()}/api/knowledge/search`
|
||||||
|
|
||||||
const response = await fetch(searchUrl, {
|
const response = await fetch(searchUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -539,8 +539,8 @@ async function executeMistralOCRRequest(
|
|||||||
const isInternalRoute = url.startsWith('/')
|
const isInternalRoute = url.startsWith('/')
|
||||||
|
|
||||||
if (isInternalRoute) {
|
if (isInternalRoute) {
|
||||||
const { getBaseUrl } = await import('@/lib/core/utils/urls')
|
const { getInternalApiBaseUrl } = await import('@/lib/core/utils/urls')
|
||||||
url = `${getBaseUrl()}${url}`
|
url = `${getInternalApiBaseUrl()}${url}`
|
||||||
}
|
}
|
||||||
|
|
||||||
let headers =
|
let headers =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEnvMock, createMockLogger } from '@sim/testing'
|
import { createEnvMock, loggerMock } from '@sim/testing'
|
||||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,10 +10,6 @@ import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
|||||||
* mock functions can intercept.
|
* mock functions can intercept.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const loggerMock = vi.hoisted(() => ({
|
|
||||||
createLogger: () => createMockLogger(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const mockSend = vi.fn()
|
const mockSend = vi.fn()
|
||||||
const mockBatchSend = vi.fn()
|
const mockBatchSend = vi.fn()
|
||||||
const mockAzureBeginSend = vi.fn()
|
const mockAzureBeginSend = vi.fn()
|
||||||
|
|||||||
@@ -1,20 +1,8 @@
|
|||||||
import { createEnvMock, createMockLogger } from '@sim/testing'
|
import { createEnvMock, databaseMock, loggerMock } from '@sim/testing'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { EmailType } from '@/lib/messaging/email/mailer'
|
import type { EmailType } from '@/lib/messaging/email/mailer'
|
||||||
|
|
||||||
const loggerMock = vi.hoisted(() => ({
|
vi.mock('@sim/db', () => databaseMock)
|
||||||
createLogger: () => createMockLogger(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const mockDb = vi.hoisted(() => ({
|
|
||||||
select: vi.fn(),
|
|
||||||
insert: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@sim/db', () => ({
|
|
||||||
db: mockDb,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@sim/db/schema', () => ({
|
vi.mock('@sim/db/schema', () => ({
|
||||||
user: { id: 'id', email: 'email' },
|
user: { id: 'id', email: 'email' },
|
||||||
@@ -30,6 +18,8 @@ vi.mock('drizzle-orm', () => ({
|
|||||||
eq: vi.fn((a, b) => ({ type: 'eq', left: a, right: b })),
|
eq: vi.fn((a, b) => ({ type: 'eq', left: a, right: b })),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const mockDb = databaseMock.db as Record<string, ReturnType<typeof vi.fn>>
|
||||||
|
|
||||||
vi.mock('@/lib/core/config/env', () => createEnvMock({ BETTER_AUTH_SECRET: 'test-secret-key' }))
|
vi.mock('@/lib/core/config/env', () => createEnvMock({ BETTER_AUTH_SECRET: 'test-secret-key' }))
|
||||||
|
|
||||||
vi.mock('@sim/logger', () => loggerMock)
|
vi.mock('@sim/logger', () => loggerMock)
|
||||||
|
|||||||
@@ -312,12 +312,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
|||||||
'read:attachment:confluence',
|
'read:attachment:confluence',
|
||||||
'write:attachment:confluence',
|
'write:attachment:confluence',
|
||||||
'search:confluence',
|
'search:confluence',
|
||||||
'read:blogpost:confluence',
|
|
||||||
'write:blogpost:confluence',
|
|
||||||
'read:content.property:confluence',
|
|
||||||
'write:content.property:confluence',
|
|
||||||
'read:hierarchical-content:confluence',
|
|
||||||
'read:content.metadata:confluence',
|
|
||||||
'read:me',
|
'read:me',
|
||||||
'offline_access',
|
'offline_access',
|
||||||
],
|
],
|
||||||
@@ -374,14 +368,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
|||||||
'read:comment.property:jira',
|
'read:comment.property:jira',
|
||||||
'read:jql:jira',
|
'read:jql:jira',
|
||||||
'read:field:jira',
|
'read:field:jira',
|
||||||
// Project management (components, versions)
|
|
||||||
'manage:jira-project',
|
|
||||||
// Jira Software / Agile scopes (no classic equivalent)
|
|
||||||
'read:board-scope:jira-software',
|
|
||||||
'write:board-scope:jira-software',
|
|
||||||
'read:sprint:jira-software',
|
|
||||||
'write:sprint:jira-software',
|
|
||||||
'delete:sprint:jira-software',
|
|
||||||
// Jira Service Management scopes
|
// Jira Service Management scopes
|
||||||
'read:servicedesk:jira-service-management',
|
'read:servicedesk:jira-service-management',
|
||||||
'read:requesttype:jira-service-management',
|
'read:requesttype:jira-service-management',
|
||||||
@@ -411,16 +397,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
|||||||
'write:request.participant:jira-service-management',
|
'write:request.participant:jira-service-management',
|
||||||
'read:request.approval:jira-service-management',
|
'read:request.approval:jira-service-management',
|
||||||
'write:request.approval:jira-service-management',
|
'write:request.approval:jira-service-management',
|
||||||
'read:request.feedback:jira-service-management',
|
|
||||||
'write:request.feedback:jira-service-management',
|
|
||||||
'delete:request.feedback:jira-service-management',
|
|
||||||
'read:request.notification:jira-service-management',
|
|
||||||
'write:request.notification:jira-service-management',
|
|
||||||
'delete:request.notification:jira-service-management',
|
|
||||||
'read:request.attachment:jira-service-management',
|
|
||||||
'read:knowledgebase:jira-service-management',
|
|
||||||
'delete:organization:jira-service-management',
|
|
||||||
'delete:servicedesk.customer:jira-service-management',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { and, eq, isNull, or, sql } from 'drizzle-orm'
|
|||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
|
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
|
||||||
import { pollingIdempotency } from '@/lib/core/idempotency/service'
|
import { pollingIdempotency } from '@/lib/core/idempotency/service'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||||
import type { GmailAttachment } from '@/tools/gmail/types'
|
import type { GmailAttachment } from '@/tools/gmail/types'
|
||||||
import { downloadAttachments, extractAttachmentInfo } from '@/tools/gmail/utils'
|
import { downloadAttachments, extractAttachmentInfo } from '@/tools/gmail/utils'
|
||||||
@@ -691,7 +691,7 @@ async function processEmails(
|
|||||||
`[${requestId}] Sending ${config.includeRawEmail ? 'simplified + raw' : 'simplified'} email payload for ${email.id}`
|
`[${requestId}] Sending ${config.includeRawEmail ? 'simplified + raw' : 'simplified'} email payload for ${email.id}`
|
||||||
)
|
)
|
||||||
|
|
||||||
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
|
const webhookUrl = `${getInternalApiBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
|
||||||
|
|
||||||
const response = await fetch(webhookUrl, {
|
const response = await fetch(webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { FetchMessageObject, MailboxLockObject } from 'imapflow'
|
|||||||
import { ImapFlow } from 'imapflow'
|
import { ImapFlow } from 'imapflow'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { pollingIdempotency } from '@/lib/core/idempotency/service'
|
import { pollingIdempotency } from '@/lib/core/idempotency/service'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
||||||
|
|
||||||
const logger = createLogger('ImapPollingService')
|
const logger = createLogger('ImapPollingService')
|
||||||
@@ -639,7 +639,7 @@ async function processEmails(
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
|
const webhookUrl = `${getInternalApiBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
|
||||||
|
|
||||||
const response = await fetch(webhookUrl, {
|
const response = await fetch(webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { htmlToText } from 'html-to-text'
|
|||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
|
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
|
||||||
import { pollingIdempotency } from '@/lib/core/idempotency'
|
import { pollingIdempotency } from '@/lib/core/idempotency'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||||
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
||||||
|
|
||||||
@@ -601,7 +601,7 @@ async function processOutlookEmails(
|
|||||||
`[${requestId}] Processing email: ${email.subject} from ${email.from?.emailAddress?.address}`
|
`[${requestId}] Processing email: ${email.subject} from ${email.from?.emailAddress?.address}`
|
||||||
)
|
)
|
||||||
|
|
||||||
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
|
const webhookUrl = `${getInternalApiBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
|
||||||
|
|
||||||
const response = await fetch(webhookUrl, {
|
const response = await fetch(webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -27,11 +27,9 @@ import {
|
|||||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||||
import { executeWebhookJob } from '@/background/webhook-execution'
|
import { executeWebhookJob } from '@/background/webhook-execution'
|
||||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||||
import { isConfluenceEventMatch } from '@/triggers/confluence/utils'
|
|
||||||
import { isGitHubEventMatch } from '@/triggers/github/utils'
|
import { isGitHubEventMatch } from '@/triggers/github/utils'
|
||||||
import { isHubSpotContactEventMatch } from '@/triggers/hubspot/utils'
|
import { isHubSpotContactEventMatch } from '@/triggers/hubspot/utils'
|
||||||
import { isJiraEventMatch } from '@/triggers/jira/utils'
|
import { isJiraEventMatch } from '@/triggers/jira/utils'
|
||||||
import { isJsmEventMatch } from '@/triggers/jsm/utils'
|
|
||||||
|
|
||||||
const logger = createLogger('WebhookProcessor')
|
const logger = createLogger('WebhookProcessor')
|
||||||
|
|
||||||
@@ -683,7 +681,7 @@ export async function verifyProviderAuth(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foundWebhook.provider === 'jira' || foundWebhook.provider === 'jira_service_management') {
|
if (foundWebhook.provider === 'jira') {
|
||||||
const secret = providerConfig.secret as string | undefined
|
const secret = providerConfig.secret as string | undefined
|
||||||
|
|
||||||
if (secret) {
|
if (secret) {
|
||||||
@@ -708,31 +706,6 @@ export async function verifyProviderAuth(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foundWebhook.provider === 'confluence') {
|
|
||||||
const secret = providerConfig.secret as string | undefined
|
|
||||||
|
|
||||||
if (secret) {
|
|
||||||
const signature = request.headers.get('X-Hub-Signature')
|
|
||||||
|
|
||||||
if (!signature) {
|
|
||||||
logger.warn(`[${requestId}] Confluence webhook missing signature header`)
|
|
||||||
return new NextResponse('Unauthorized - Missing Confluence signature', { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidSignature = validateJiraSignature(secret, signature, rawBody)
|
|
||||||
|
|
||||||
if (!isValidSignature) {
|
|
||||||
logger.warn(`[${requestId}] Confluence signature verification failed`, {
|
|
||||||
signatureLength: signature.length,
|
|
||||||
secretLength: secret.length,
|
|
||||||
})
|
|
||||||
return new NextResponse('Unauthorized - Invalid Confluence signature', { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(`[${requestId}] Confluence signature verified successfully`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (foundWebhook.provider === 'github') {
|
if (foundWebhook.provider === 'github') {
|
||||||
const secret = providerConfig.secret as string | undefined
|
const secret = providerConfig.secret as string | undefined
|
||||||
|
|
||||||
@@ -956,60 +929,6 @@ export async function queueWebhookExecution(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSM event filtering for event-specific triggers
|
|
||||||
if (foundWebhook.provider === 'jira_service_management') {
|
|
||||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
|
||||||
const triggerId = providerConfig.triggerId as string | undefined
|
|
||||||
|
|
||||||
if (triggerId && triggerId !== 'jsm_webhook') {
|
|
||||||
const webhookEvent = body.webhookEvent as string | undefined
|
|
||||||
|
|
||||||
if (!isJsmEventMatch(triggerId, webhookEvent || '', body)) {
|
|
||||||
logger.debug(
|
|
||||||
`[${options.requestId}] JSM event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`,
|
|
||||||
{
|
|
||||||
webhookId: foundWebhook.id,
|
|
||||||
workflowId: foundWorkflow.id,
|
|
||||||
triggerId,
|
|
||||||
receivedEvent: webhookEvent,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Return 200 OK to prevent Jira from retrying
|
|
||||||
return NextResponse.json({
|
|
||||||
message: 'Event type does not match trigger configuration. Ignoring.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confluence event filtering for event-specific triggers
|
|
||||||
if (foundWebhook.provider === 'confluence') {
|
|
||||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
|
||||||
const triggerId = providerConfig.triggerId as string | undefined
|
|
||||||
|
|
||||||
if (triggerId && triggerId !== 'confluence_webhook') {
|
|
||||||
const event = body.event as string | undefined
|
|
||||||
|
|
||||||
if (!isConfluenceEventMatch(triggerId, event || '')) {
|
|
||||||
logger.debug(
|
|
||||||
`[${options.requestId}] Confluence event mismatch for trigger ${triggerId}. Event: ${event}. Skipping execution.`,
|
|
||||||
{
|
|
||||||
webhookId: foundWebhook.id,
|
|
||||||
workflowId: foundWorkflow.id,
|
|
||||||
triggerId,
|
|
||||||
receivedEvent: event,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Return 200 OK to prevent Confluence from retrying
|
|
||||||
return NextResponse.json({
|
|
||||||
message: 'Event type does not match trigger configuration. Ignoring.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (foundWebhook.provider === 'hubspot') {
|
if (foundWebhook.provider === 'hubspot') {
|
||||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||||
const triggerId = providerConfig.triggerId as string | undefined
|
const triggerId = providerConfig.triggerId as string | undefined
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ const PROVIDER_EXTRACTORS: Record<string, (body: any) => string | null> = {
|
|||||||
hubspot: extractHubSpotIdentifier,
|
hubspot: extractHubSpotIdentifier,
|
||||||
linear: extractLinearIdentifier,
|
linear: extractLinearIdentifier,
|
||||||
jira: extractJiraIdentifier,
|
jira: extractJiraIdentifier,
|
||||||
jira_service_management: extractJiraIdentifier,
|
|
||||||
'microsoft-teams': extractMicrosoftTeamsIdentifier,
|
'microsoft-teams': extractMicrosoftTeamsIdentifier,
|
||||||
airtable: extractAirtableIdentifier,
|
airtable: extractAirtableIdentifier,
|
||||||
grain: extractGrainIdentifier,
|
grain: extractGrainIdentifier,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
secureFetchWithPinnedIP,
|
secureFetchWithPinnedIP,
|
||||||
validateUrlWithDNS,
|
validateUrlWithDNS,
|
||||||
} from '@/lib/core/security/input-validation.server'
|
} from '@/lib/core/security/input-validation.server'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
||||||
|
|
||||||
const logger = createLogger('RssPollingService')
|
const logger = createLogger('RssPollingService')
|
||||||
@@ -376,7 +376,7 @@ async function processRssItems(
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
|
const webhookUrl = `${getInternalApiBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
|
||||||
|
|
||||||
const response = await fetch(webhookUrl, {
|
const response = await fetch(webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -530,9 +530,6 @@ export async function validateTwilioSignature(
|
|||||||
const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
|
const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
|
||||||
const SLACK_MAX_FILES = 15
|
const SLACK_MAX_FILES = 15
|
||||||
|
|
||||||
const JIRA_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
|
|
||||||
const CONFLUENCE_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the full file object from the Slack API when the event payload
|
* Resolves the full file object from the Slack API when the event payload
|
||||||
* only contains a partial file (e.g. missing url_private due to file_access restrictions).
|
* only contains a partial file (e.g. missing url_private due to file_access restrictions).
|
||||||
@@ -682,169 +679,6 @@ async function downloadSlackFiles(
|
|||||||
return downloaded
|
return downloaded
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads a Jira attachment file using Basic auth (email + API token).
|
|
||||||
* Returns the file data in the format expected by WebhookAttachmentProcessor.
|
|
||||||
*/
|
|
||||||
async function downloadJiraAttachment(
|
|
||||||
attachment: { content?: string; filename?: string; mimeType?: string; size?: number },
|
|
||||||
apiEmail: string,
|
|
||||||
apiToken: string
|
|
||||||
): Promise<{ name: string; data: string; mimeType: string; size: number } | null> {
|
|
||||||
const contentUrl = attachment.content
|
|
||||||
if (!contentUrl) {
|
|
||||||
logger.warn('Jira attachment has no content URL, skipping download')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const reportedSize = Number(attachment.size) || 0
|
|
||||||
if (reportedSize > JIRA_MAX_FILE_SIZE) {
|
|
||||||
logger.warn('Jira attachment exceeds size limit, skipping', {
|
|
||||||
filename: attachment.filename,
|
|
||||||
size: reportedSize,
|
|
||||||
limit: JIRA_MAX_FILE_SIZE,
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const urlValidation = await validateUrlWithDNS(contentUrl, 'attachment_content')
|
|
||||||
if (!urlValidation.isValid) {
|
|
||||||
logger.warn('Jira attachment URL failed DNS validation, skipping', {
|
|
||||||
filename: attachment.filename,
|
|
||||||
error: urlValidation.error,
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const authHeader = Buffer.from(`${apiEmail}:${apiToken}`).toString('base64')
|
|
||||||
|
|
||||||
const response = await secureFetchWithPinnedIP(contentUrl, urlValidation.resolvedIP!, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Basic ${authHeader}`,
|
|
||||||
Accept: '*/*',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
logger.warn('Failed to download Jira attachment', {
|
|
||||||
filename: attachment.filename,
|
|
||||||
status: response.status,
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer()
|
|
||||||
const buffer = Buffer.from(arrayBuffer)
|
|
||||||
|
|
||||||
if (buffer.length > JIRA_MAX_FILE_SIZE) {
|
|
||||||
logger.warn('Downloaded Jira attachment exceeds size limit, skipping', {
|
|
||||||
filename: attachment.filename,
|
|
||||||
actualSize: buffer.length,
|
|
||||||
limit: JIRA_MAX_FILE_SIZE,
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: attachment.filename || 'attachment',
|
|
||||||
data: buffer.toString('base64'),
|
|
||||||
mimeType: attachment.mimeType || 'application/octet-stream',
|
|
||||||
size: buffer.length,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error downloading Jira attachment', {
|
|
||||||
filename: attachment.filename,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads a Confluence attachment file using Atlassian Basic Auth.
|
|
||||||
* Constructs the download URL from the domain and attachment download path.
|
|
||||||
*/
|
|
||||||
async function downloadConfluenceAttachment(
|
|
||||||
attachment: Record<string, any>,
|
|
||||||
domain: string,
|
|
||||||
apiEmail: string,
|
|
||||||
apiToken: string
|
|
||||||
): Promise<{ name: string; data: string; mimeType: string; size: number } | null> {
|
|
||||||
// Confluence webhook payload includes _links.download for the attachment
|
|
||||||
const downloadPath = attachment?._links?.download || attachment?._expandable?.download || null
|
|
||||||
const attachmentId = attachment?.id
|
|
||||||
|
|
||||||
if (!downloadPath && !attachmentId) {
|
|
||||||
logger.warn('Confluence attachment has no download path or ID, skipping download')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const reportedSize = Number(attachment?.extensions?.fileSize || attachment?.fileSize || 0)
|
|
||||||
if (reportedSize > CONFLUENCE_MAX_FILE_SIZE) {
|
|
||||||
logger.warn('Confluence attachment exceeds size limit, skipping', {
|
|
||||||
title: attachment?.title,
|
|
||||||
size: reportedSize,
|
|
||||||
limit: CONFLUENCE_MAX_FILE_SIZE,
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the download URL
|
|
||||||
const cleanDomain = domain.replace(/\/+$/, '')
|
|
||||||
const baseUrl = cleanDomain.startsWith('http') ? cleanDomain : `https://${cleanDomain}`
|
|
||||||
const downloadUrl = downloadPath
|
|
||||||
? `${baseUrl}/wiki${downloadPath}`
|
|
||||||
: `${baseUrl}/wiki/rest/api/content/${attachmentId}/download`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const authHeader = Buffer.from(`${apiEmail}:${apiToken}`).toString('base64')
|
|
||||||
|
|
||||||
const response = await fetch(downloadUrl, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Basic ${authHeader}`,
|
|
||||||
Accept: '*/*',
|
|
||||||
'X-Atlassian-Token': 'no-check',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
logger.warn('Failed to download Confluence attachment', {
|
|
||||||
title: attachment?.title,
|
|
||||||
status: response.status,
|
|
||||||
url: sanitizeUrlForLog(downloadUrl),
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer()
|
|
||||||
const buffer = Buffer.from(arrayBuffer)
|
|
||||||
|
|
||||||
if (buffer.length > CONFLUENCE_MAX_FILE_SIZE) {
|
|
||||||
logger.warn('Downloaded Confluence attachment exceeds size limit, skipping', {
|
|
||||||
title: attachment?.title,
|
|
||||||
actualSize: buffer.length,
|
|
||||||
limit: CONFLUENCE_MAX_FILE_SIZE,
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: attachment?.title || 'attachment',
|
|
||||||
data: buffer.toString('base64'),
|
|
||||||
mimeType:
|
|
||||||
attachment?.extensions?.mediaType || attachment?.mediaType || 'application/octet-stream',
|
|
||||||
size: buffer.length,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error downloading Confluence attachment', {
|
|
||||||
title: attachment?.title,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format webhook input based on provider
|
* Format webhook input based on provider
|
||||||
*/
|
*/
|
||||||
@@ -1269,156 +1103,22 @@ export async function formatWebhookInput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (foundWebhook.provider === 'jira') {
|
if (foundWebhook.provider === 'jira') {
|
||||||
const {
|
const { extractIssueData, extractCommentData, extractWorklogData } = await import(
|
||||||
extractIssueData,
|
'@/triggers/jira/utils'
|
||||||
extractCommentData,
|
)
|
||||||
extractWorklogData,
|
|
||||||
extractAttachmentData,
|
|
||||||
extractSprintData,
|
|
||||||
extractProjectData,
|
|
||||||
extractVersionData,
|
|
||||||
extractBoardData,
|
|
||||||
extractIssueLinkData,
|
|
||||||
} = await import('@/triggers/jira/utils')
|
|
||||||
|
|
||||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||||
const triggerId = providerConfig.triggerId as string | undefined
|
const triggerId = providerConfig.triggerId as string | undefined
|
||||||
|
|
||||||
if (
|
if (triggerId === 'jira_issue_commented') {
|
||||||
triggerId === 'jira_issue_commented' ||
|
|
||||||
triggerId === 'jira_comment_updated' ||
|
|
||||||
triggerId === 'jira_comment_deleted'
|
|
||||||
) {
|
|
||||||
return extractCommentData(body)
|
return extractCommentData(body)
|
||||||
}
|
}
|
||||||
if (
|
if (triggerId === 'jira_worklog_created') {
|
||||||
triggerId === 'jira_worklog_created' ||
|
|
||||||
triggerId === 'jira_worklog_updated' ||
|
|
||||||
triggerId === 'jira_worklog_deleted'
|
|
||||||
) {
|
|
||||||
return extractWorklogData(body)
|
return extractWorklogData(body)
|
||||||
}
|
}
|
||||||
if (triggerId === 'jira_attachment_created' || triggerId === 'jira_attachment_deleted') {
|
|
||||||
const result = extractAttachmentData(body)
|
|
||||||
|
|
||||||
// Download the attachment file if configured
|
|
||||||
if (triggerId === 'jira_attachment_created') {
|
|
||||||
const apiEmail = providerConfig.apiEmail as string | undefined
|
|
||||||
const apiToken = providerConfig.apiToken as string | undefined
|
|
||||||
const includeAttachments = Boolean(providerConfig.includeAttachments)
|
|
||||||
|
|
||||||
if (includeAttachments && apiEmail && apiToken && result.attachment?.content) {
|
|
||||||
const downloaded = await downloadJiraAttachment(result.attachment, apiEmail, apiToken)
|
|
||||||
if (downloaded) {
|
|
||||||
result.attachments = [downloaded]
|
|
||||||
}
|
|
||||||
} else if (includeAttachments && (!apiEmail || !apiToken)) {
|
|
||||||
logger.warn(
|
|
||||||
'Jira attachment trigger has includeAttachments enabled but missing API credentials'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if (triggerId?.startsWith('jira_sprint_')) {
|
|
||||||
return extractSprintData(body)
|
|
||||||
}
|
|
||||||
if (triggerId?.startsWith('jira_project_')) {
|
|
||||||
return extractProjectData(body)
|
|
||||||
}
|
|
||||||
if (triggerId?.startsWith('jira_version_')) {
|
|
||||||
return extractVersionData(body)
|
|
||||||
}
|
|
||||||
if (triggerId?.startsWith('jira_board_')) {
|
|
||||||
return extractBoardData(body)
|
|
||||||
}
|
|
||||||
if (triggerId?.startsWith('jira_issuelink_')) {
|
|
||||||
return extractIssueLinkData(body)
|
|
||||||
}
|
|
||||||
return extractIssueData(body)
|
return extractIssueData(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foundWebhook.provider === 'jira_service_management') {
|
|
||||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
|
||||||
const triggerId = providerConfig.triggerId as string | undefined
|
|
||||||
const includeFiles = Boolean(providerConfig.includeFiles)
|
|
||||||
const jiraEmail = providerConfig.jiraEmail as string | undefined
|
|
||||||
const jiraApiToken = providerConfig.jiraApiToken as string | undefined
|
|
||||||
|
|
||||||
const webhookEvent = body.webhookEvent || ''
|
|
||||||
|
|
||||||
// Base data common to all JSM events
|
|
||||||
const baseData: Record<string, any> = {
|
|
||||||
webhookEvent,
|
|
||||||
timestamp: body.timestamp,
|
|
||||||
issue: body.issue || {},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle attachment events
|
|
||||||
if (
|
|
||||||
triggerId === 'jsm_attachment_created' ||
|
|
||||||
triggerId === 'jsm_attachment_deleted' ||
|
|
||||||
webhookEvent.includes('attachment')
|
|
||||||
) {
|
|
||||||
const attachment = body.attachment || {}
|
|
||||||
baseData.attachment = attachment
|
|
||||||
|
|
||||||
let files: Array<{ name: string; data: string; mimeType: string; size: number }> = []
|
|
||||||
|
|
||||||
if (
|
|
||||||
webhookEvent.includes('attachment_created') &&
|
|
||||||
includeFiles &&
|
|
||||||
jiraEmail &&
|
|
||||||
jiraApiToken &&
|
|
||||||
attachment.content
|
|
||||||
) {
|
|
||||||
const downloaded = await downloadJiraAttachment(attachment, jiraEmail, jiraApiToken)
|
|
||||||
if (downloaded) {
|
|
||||||
files = [downloaded]
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
webhookEvent.includes('attachment_created') &&
|
|
||||||
includeFiles &&
|
|
||||||
(!jiraEmail || !jiraApiToken)
|
|
||||||
) {
|
|
||||||
logger.warn(
|
|
||||||
'JSM attachment trigger has includeFiles enabled but missing Jira API credentials'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
baseData.files = files
|
|
||||||
return baseData
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle comment events
|
|
||||||
if (
|
|
||||||
triggerId === 'jsm_request_commented' ||
|
|
||||||
triggerId === 'jsm_comment_updated' ||
|
|
||||||
triggerId === 'jsm_comment_deleted' ||
|
|
||||||
webhookEvent.includes('comment')
|
|
||||||
) {
|
|
||||||
baseData.comment = body.comment || {}
|
|
||||||
return baseData
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle worklog events
|
|
||||||
if (
|
|
||||||
triggerId === 'jsm_worklog_created' ||
|
|
||||||
triggerId === 'jsm_worklog_updated' ||
|
|
||||||
triggerId === 'jsm_worklog_deleted' ||
|
|
||||||
webhookEvent.includes('worklog')
|
|
||||||
) {
|
|
||||||
baseData.worklog = body.worklog || {}
|
|
||||||
return baseData
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: request events (created/updated/deleted) and generic webhook
|
|
||||||
baseData.issue_event_type_name = body.issue_event_type_name
|
|
||||||
baseData.changelog = body.changelog
|
|
||||||
return baseData
|
|
||||||
}
|
|
||||||
|
|
||||||
if (foundWebhook.provider === 'stripe') {
|
if (foundWebhook.provider === 'stripe') {
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
@@ -1467,70 +1167,6 @@ export async function formatWebhookInput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foundWebhook.provider === 'confluence') {
|
|
||||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
|
||||||
const event = body.event as string | undefined
|
|
||||||
const result: Record<string, unknown> = {
|
|
||||||
event: event || '',
|
|
||||||
timestamp: body.timestamp,
|
|
||||||
userAccountId: body.userAccountId || '',
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.page) {
|
|
||||||
result.page = body.page
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.comment) {
|
|
||||||
result.comment = body.comment
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.blog || body.blogpost) {
|
|
||||||
result.blog = body.blog || body.blogpost
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.attachment) {
|
|
||||||
result.attachment = body.attachment
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.space) {
|
|
||||||
result.space = body.space
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.label) {
|
|
||||||
result.label = body.label
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.content) {
|
|
||||||
result.content = body.content
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download attachment file content when configured
|
|
||||||
const includeFileContent = Boolean(providerConfig.includeFileContent)
|
|
||||||
const confluenceEmail = providerConfig.confluenceEmail as string | undefined
|
|
||||||
const confluenceApiToken = providerConfig.confluenceApiToken as string | undefined
|
|
||||||
const confluenceDomain = providerConfig.confluenceDomain as string | undefined
|
|
||||||
|
|
||||||
if (body.attachment && includeFileContent) {
|
|
||||||
if (confluenceEmail && confluenceApiToken && confluenceDomain) {
|
|
||||||
const downloaded = await downloadConfluenceAttachment(
|
|
||||||
body.attachment,
|
|
||||||
confluenceDomain,
|
|
||||||
confluenceEmail,
|
|
||||||
confluenceApiToken
|
|
||||||
)
|
|
||||||
if (downloaded) {
|
|
||||||
result.files = [downloaded]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
'Confluence attachment trigger has includeFileContent enabled but missing credentials (email, API token, or domain)'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -645,6 +645,18 @@ describe('Workflow Normalization Utilities', () => {
|
|||||||
const result = filterSubBlockIds(ids)
|
const result = filterSubBlockIds(ids)
|
||||||
expect(result).toEqual(['signingSecret'])
|
expect(result).toEqual(['signingSecret'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.concurrent('should exclude synthetic tool-input subBlock IDs', () => {
|
||||||
|
const ids = [
|
||||||
|
'toolConfig',
|
||||||
|
'toolConfig-tool-0-query',
|
||||||
|
'toolConfig-tool-0-url',
|
||||||
|
'toolConfig-tool-1-status',
|
||||||
|
'systemPrompt',
|
||||||
|
]
|
||||||
|
const result = filterSubBlockIds(ids)
|
||||||
|
expect(result).toEqual(['systemPrompt', 'toolConfig'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('normalizeTriggerConfigValues', () => {
|
describe('normalizeTriggerConfigValues', () => {
|
||||||
|
|||||||
@@ -411,7 +411,14 @@ export function extractBlockFieldsForComparison(block: BlockState): ExtractedBlo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters subBlock IDs to exclude system and trigger runtime subBlocks.
|
* Pattern matching synthetic subBlock IDs created by ToolSubBlockRenderer.
|
||||||
|
* These IDs follow the format `{subBlockId}-tool-{index}-{paramId}` and are
|
||||||
|
* mirrors of values already stored in toolConfig.value.tools[N].params.
|
||||||
|
*/
|
||||||
|
const SYNTHETIC_TOOL_SUBBLOCK_RE = /-tool-\d+-/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters subBlock IDs to exclude system, trigger runtime, and synthetic tool subBlocks.
|
||||||
*
|
*
|
||||||
* @param subBlockIds - Array of subBlock IDs to filter
|
* @param subBlockIds - Array of subBlock IDs to filter
|
||||||
* @returns Filtered and sorted array of subBlock IDs
|
* @returns Filtered and sorted array of subBlock IDs
|
||||||
@@ -422,6 +429,7 @@ export function filterSubBlockIds(subBlockIds: string[]): string[] {
|
|||||||
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id)) return false
|
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(id)) return false
|
||||||
if (SYSTEM_SUBBLOCK_IDS.some((sysId) => id === sysId || id.startsWith(`${sysId}_`)))
|
if (SYSTEM_SUBBLOCK_IDS.some((sysId) => id === sysId || id.startsWith(`${sysId}_`)))
|
||||||
return false
|
return false
|
||||||
|
if (SYNTHETIC_TOOL_SUBBLOCK_RE.test(id)) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.sort()
|
.sort()
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
import { loggerMock } from '@sim/testing'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||||
|
|
||||||
// Mock all external dependencies before imports
|
vi.mock('@sim/logger', () => loggerMock)
|
||||||
vi.mock('@sim/logger', () => ({
|
|
||||||
createLogger: () => ({
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/stores/workflows/workflow/store', () => ({
|
vi.mock('@/stores/workflows/workflow/store', () => ({
|
||||||
useWorkflowStore: {
|
useWorkflowStore: {
|
||||||
|
|||||||
@@ -14,22 +14,15 @@ import {
|
|||||||
databaseMock,
|
databaseMock,
|
||||||
expectWorkflowAccessDenied,
|
expectWorkflowAccessDenied,
|
||||||
expectWorkflowAccessGranted,
|
expectWorkflowAccessGranted,
|
||||||
|
mockAuth,
|
||||||
} from '@sim/testing'
|
} from '@sim/testing'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
vi.mock('@sim/db', () => databaseMock)
|
const mockDb = databaseMock.db
|
||||||
|
|
||||||
// Mock the auth module
|
|
||||||
vi.mock('@/lib/auth', () => ({
|
|
||||||
getSession: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { db } from '@sim/db'
|
|
||||||
import { getSession } from '@/lib/auth'
|
|
||||||
// Import after mocks are set up
|
|
||||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
|
||||||
|
|
||||||
describe('validateWorkflowPermissions', () => {
|
describe('validateWorkflowPermissions', () => {
|
||||||
|
const auth = mockAuth()
|
||||||
|
|
||||||
const mockSession = createSession({ userId: 'user-1', email: 'user1@test.com' })
|
const mockSession = createSession({ userId: 'user-1', email: 'user1@test.com' })
|
||||||
const mockWorkflow = createWorkflowRecord({
|
const mockWorkflow = createWorkflowRecord({
|
||||||
id: 'wf-1',
|
id: 'wf-1',
|
||||||
@@ -42,13 +35,17 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
vi.doMock('@sim/db', () => databaseMock)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('authentication', () => {
|
describe('authentication', () => {
|
||||||
it('should return 401 when no session exists', async () => {
|
it('should return 401 when no session exists', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue(null)
|
auth.setUnauthenticated()
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
||||||
|
|
||||||
expectWorkflowAccessDenied(result, 401)
|
expectWorkflowAccessDenied(result, 401)
|
||||||
@@ -56,8 +53,9 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return 401 when session has no user id', async () => {
|
it('should return 401 when session has no user id', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue({ user: {} } as any)
|
auth.mockGetSession.mockResolvedValue({ user: {} } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
||||||
|
|
||||||
expectWorkflowAccessDenied(result, 401)
|
expectWorkflowAccessDenied(result, 401)
|
||||||
@@ -66,14 +64,14 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
|
|
||||||
describe('workflow not found', () => {
|
describe('workflow not found', () => {
|
||||||
it('should return 404 when workflow does not exist', async () => {
|
it('should return 404 when workflow does not exist', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue(mockSession as any)
|
auth.mockGetSession.mockResolvedValue(mockSession as any)
|
||||||
|
|
||||||
// Mock workflow query to return empty
|
|
||||||
const mockLimit = vi.fn().mockResolvedValue([])
|
const mockLimit = vi.fn().mockResolvedValue([])
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('non-existent', 'req-1', 'read')
|
const result = await validateWorkflowPermissions('non-existent', 'req-1', 'read')
|
||||||
|
|
||||||
expectWorkflowAccessDenied(result, 404)
|
expectWorkflowAccessDenied(result, 404)
|
||||||
@@ -83,43 +81,42 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
|
|
||||||
describe('owner access', () => {
|
describe('owner access', () => {
|
||||||
it('should deny access to workflow owner without workspace permissions for read action', async () => {
|
it('should deny access to workflow owner without workspace permissions for read action', async () => {
|
||||||
const ownerSession = createSession({ userId: 'owner-1' })
|
auth.setAuthenticated({ id: 'owner-1', email: 'owner-1@test.com' })
|
||||||
vi.mocked(getSession).mockResolvedValue(ownerSession as any)
|
|
||||||
|
|
||||||
// Mock workflow query
|
|
||||||
const mockLimit = vi.fn().mockResolvedValue([mockWorkflow])
|
const mockLimit = vi.fn().mockResolvedValue([mockWorkflow])
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
||||||
|
|
||||||
expectWorkflowAccessDenied(result, 403)
|
expectWorkflowAccessDenied(result, 403)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should deny access to workflow owner without workspace permissions for write action', async () => {
|
it('should deny access to workflow owner without workspace permissions for write action', async () => {
|
||||||
const ownerSession = createSession({ userId: 'owner-1' })
|
auth.setAuthenticated({ id: 'owner-1', email: 'owner-1@test.com' })
|
||||||
vi.mocked(getSession).mockResolvedValue(ownerSession as any)
|
|
||||||
|
|
||||||
const mockLimit = vi.fn().mockResolvedValue([mockWorkflow])
|
const mockLimit = vi.fn().mockResolvedValue([mockWorkflow])
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write')
|
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write')
|
||||||
|
|
||||||
expectWorkflowAccessDenied(result, 403)
|
expectWorkflowAccessDenied(result, 403)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should deny access to workflow owner without workspace permissions for admin action', async () => {
|
it('should deny access to workflow owner without workspace permissions for admin action', async () => {
|
||||||
const ownerSession = createSession({ userId: 'owner-1' })
|
auth.setAuthenticated({ id: 'owner-1', email: 'owner-1@test.com' })
|
||||||
vi.mocked(getSession).mockResolvedValue(ownerSession as any)
|
|
||||||
|
|
||||||
const mockLimit = vi.fn().mockResolvedValue([mockWorkflow])
|
const mockLimit = vi.fn().mockResolvedValue([mockWorkflow])
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin')
|
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin')
|
||||||
|
|
||||||
expectWorkflowAccessDenied(result, 403)
|
expectWorkflowAccessDenied(result, 403)
|
||||||
@@ -128,11 +125,10 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
|
|
||||||
describe('workspace member access with permissions', () => {
|
describe('workspace member access with permissions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getSession).mockResolvedValue(mockSession as any)
|
auth.mockGetSession.mockResolvedValue(mockSession as any)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should grant read access to user with read permission', async () => {
|
it('should grant read access to user with read permission', async () => {
|
||||||
// First call: workflow query, second call: workspace owner, third call: permission
|
|
||||||
let callCount = 0
|
let callCount = 0
|
||||||
const mockLimit = vi.fn().mockImplementation(() => {
|
const mockLimit = vi.fn().mockImplementation(() => {
|
||||||
callCount++
|
callCount++
|
||||||
@@ -141,8 +137,9 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
})
|
})
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
||||||
|
|
||||||
expectWorkflowAccessGranted(result)
|
expectWorkflowAccessGranted(result)
|
||||||
@@ -157,8 +154,9 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
})
|
})
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write')
|
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write')
|
||||||
|
|
||||||
expectWorkflowAccessDenied(result, 403)
|
expectWorkflowAccessDenied(result, 403)
|
||||||
@@ -174,8 +172,9 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
})
|
})
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write')
|
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write')
|
||||||
|
|
||||||
expectWorkflowAccessGranted(result)
|
expectWorkflowAccessGranted(result)
|
||||||
@@ -190,8 +189,9 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
})
|
})
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write')
|
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'write')
|
||||||
|
|
||||||
expectWorkflowAccessGranted(result)
|
expectWorkflowAccessGranted(result)
|
||||||
@@ -206,8 +206,9 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
})
|
})
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin')
|
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin')
|
||||||
|
|
||||||
expectWorkflowAccessDenied(result, 403)
|
expectWorkflowAccessDenied(result, 403)
|
||||||
@@ -223,8 +224,9 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
})
|
})
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin')
|
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'admin')
|
||||||
|
|
||||||
expectWorkflowAccessGranted(result)
|
expectWorkflowAccessGranted(result)
|
||||||
@@ -233,18 +235,19 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
|
|
||||||
describe('no workspace permission', () => {
|
describe('no workspace permission', () => {
|
||||||
it('should deny access to user without any workspace permission', async () => {
|
it('should deny access to user without any workspace permission', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue(mockSession as any)
|
auth.mockGetSession.mockResolvedValue(mockSession as any)
|
||||||
|
|
||||||
let callCount = 0
|
let callCount = 0
|
||||||
const mockLimit = vi.fn().mockImplementation(() => {
|
const mockLimit = vi.fn().mockImplementation(() => {
|
||||||
callCount++
|
callCount++
|
||||||
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
if (callCount === 1) return Promise.resolve([mockWorkflow])
|
||||||
return Promise.resolve([]) // No permission record
|
return Promise.resolve([])
|
||||||
})
|
})
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
const result = await validateWorkflowPermissions('wf-1', 'req-1', 'read')
|
||||||
|
|
||||||
expectWorkflowAccessDenied(result, 403)
|
expectWorkflowAccessDenied(result, 403)
|
||||||
@@ -259,13 +262,14 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
workspaceId: null,
|
workspaceId: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mocked(getSession).mockResolvedValue(mockSession as any)
|
auth.mockGetSession.mockResolvedValue(mockSession as any)
|
||||||
|
|
||||||
const mockLimit = vi.fn().mockResolvedValue([workflowWithoutWorkspace])
|
const mockLimit = vi.fn().mockResolvedValue([workflowWithoutWorkspace])
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-2', 'req-1', 'read')
|
const result = await validateWorkflowPermissions('wf-2', 'req-1', 'read')
|
||||||
|
|
||||||
expectWorkflowAccessDenied(result, 403)
|
expectWorkflowAccessDenied(result, 403)
|
||||||
@@ -278,13 +282,14 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
workspaceId: null,
|
workspaceId: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mocked(getSession).mockResolvedValue(mockSession as any)
|
auth.mockGetSession.mockResolvedValue(mockSession as any)
|
||||||
|
|
||||||
const mockLimit = vi.fn().mockResolvedValue([workflowWithoutWorkspace])
|
const mockLimit = vi.fn().mockResolvedValue([workflowWithoutWorkspace])
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-2', 'req-1', 'read')
|
const result = await validateWorkflowPermissions('wf-2', 'req-1', 'read')
|
||||||
|
|
||||||
expectWorkflowAccessDenied(result, 403)
|
expectWorkflowAccessDenied(result, 403)
|
||||||
@@ -293,7 +298,7 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
|
|
||||||
describe('default action', () => {
|
describe('default action', () => {
|
||||||
it('should default to read action when not specified', async () => {
|
it('should default to read action when not specified', async () => {
|
||||||
vi.mocked(getSession).mockResolvedValue(mockSession as any)
|
auth.mockGetSession.mockResolvedValue(mockSession as any)
|
||||||
|
|
||||||
let callCount = 0
|
let callCount = 0
|
||||||
const mockLimit = vi.fn().mockImplementation(() => {
|
const mockLimit = vi.fn().mockImplementation(() => {
|
||||||
@@ -303,8 +308,9 @@ describe('validateWorkflowPermissions', () => {
|
|||||||
})
|
})
|
||||||
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
const mockWhere = vi.fn(() => ({ limit: mockLimit }))
|
||||||
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
const mockFrom = vi.fn(() => ({ where: mockWhere }))
|
||||||
vi.mocked(db.select).mockReturnValue({ from: mockFrom } as any)
|
vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any)
|
||||||
|
|
||||||
|
const { validateWorkflowPermissions } = await import('@/lib/workflows/utils')
|
||||||
const result = await validateWorkflowPermissions('wf-1', 'req-1')
|
const result = await validateWorkflowPermissions('wf-1', 'req-1')
|
||||||
|
|
||||||
expectWorkflowAccessGranted(result)
|
expectWorkflowAccessGranted(result)
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
import { drizzleOrmMock } from '@sim/testing/mocks'
|
import { databaseMock, drizzleOrmMock } from '@sim/testing'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
vi.mock('@sim/db', () => ({
|
vi.mock('@sim/db', () => databaseMock)
|
||||||
db: {
|
|
||||||
select: vi.fn(),
|
|
||||||
from: vi.fn(),
|
|
||||||
where: vi.fn(),
|
|
||||||
limit: vi.fn(),
|
|
||||||
innerJoin: vi.fn(),
|
|
||||||
leftJoin: vi.fn(),
|
|
||||||
orderBy: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@sim/db/schema', () => ({
|
vi.mock('@sim/db/schema', () => ({
|
||||||
permissions: {
|
permissions: {
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ export interface ProviderToolConfig {
|
|||||||
required: string[]
|
required: string[]
|
||||||
}
|
}
|
||||||
usageControl?: ToolUsageControl
|
usageControl?: ToolUsageControl
|
||||||
|
/** Block-level params transformer — converts SubBlock values to tool-ready params */
|
||||||
|
paramsTransform?: (params: Record<string, any>) => Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user