From 6065942497806656c83b6fb2574d6d0f84ee1a8b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Apr 2026 18:08:19 -0700 Subject: [PATCH 1/5] feat(jsm): add all Forms API endpoints for two-step form workflow --- .../docs/en/tools/jira_service_management.mdx | 231 +++++++++++++++ .../integrations/data/integrations.json | 42 ++- .../app/api/tools/jsm/forms/answers/route.ts | 111 ++++++++ .../app/api/tools/jsm/forms/attach/route.ts | 114 ++++++++ .../sim/app/api/tools/jsm/forms/copy/route.ts | 122 ++++++++ .../app/api/tools/jsm/forms/delete/route.ts | 111 ++++++++ .../api/tools/jsm/forms/externalise/route.ts | 111 ++++++++ apps/sim/app/api/tools/jsm/forms/get/route.ts | 113 ++++++++ .../api/tools/jsm/forms/internalise/route.ts | 111 ++++++++ .../app/api/tools/jsm/forms/reopen/route.ts | 111 ++++++++ .../sim/app/api/tools/jsm/forms/save/route.ts | 118 ++++++++ .../app/api/tools/jsm/forms/submit/route.ts | 111 ++++++++ .../blocks/blocks/jira_service_management.ts | 267 +++++++++++++++++- apps/sim/tools/jsm/attach_form.ts | 122 ++++++++ apps/sim/tools/jsm/copy_forms.ts | 98 +++++++ apps/sim/tools/jsm/delete_form.ts | 82 ++++++ apps/sim/tools/jsm/externalise_form.ts | 88 ++++++ apps/sim/tools/jsm/get_form.ts | 122 ++++++++ apps/sim/tools/jsm/get_form_answers.ts | 109 +++++++ apps/sim/tools/jsm/index.ts | 20 ++ apps/sim/tools/jsm/internalise_form.ts | 89 ++++++ apps/sim/tools/jsm/reopen_form.ts | 106 +++++++ apps/sim/tools/jsm/save_form_answers.ts | 121 ++++++++ apps/sim/tools/jsm/submit_form.ts | 106 +++++++ apps/sim/tools/jsm/types.ts | 205 ++++++++++++++ apps/sim/tools/registry.ts | 20 ++ 26 files changed, 2957 insertions(+), 4 deletions(-) create mode 100644 apps/sim/app/api/tools/jsm/forms/answers/route.ts create mode 100644 apps/sim/app/api/tools/jsm/forms/attach/route.ts create mode 100644 apps/sim/app/api/tools/jsm/forms/copy/route.ts create mode 100644 apps/sim/app/api/tools/jsm/forms/delete/route.ts create mode 100644 apps/sim/app/api/tools/jsm/forms/externalise/route.ts create mode 100644 apps/sim/app/api/tools/jsm/forms/get/route.ts create mode 100644 apps/sim/app/api/tools/jsm/forms/internalise/route.ts create mode 100644 apps/sim/app/api/tools/jsm/forms/reopen/route.ts create mode 100644 apps/sim/app/api/tools/jsm/forms/save/route.ts create mode 100644 apps/sim/app/api/tools/jsm/forms/submit/route.ts create mode 100644 apps/sim/tools/jsm/attach_form.ts create mode 100644 apps/sim/tools/jsm/copy_forms.ts create mode 100644 apps/sim/tools/jsm/delete_form.ts create mode 100644 apps/sim/tools/jsm/externalise_form.ts create mode 100644 apps/sim/tools/jsm/get_form.ts create mode 100644 apps/sim/tools/jsm/get_form_answers.ts create mode 100644 apps/sim/tools/jsm/internalise_form.ts create mode 100644 apps/sim/tools/jsm/reopen_form.ts create mode 100644 apps/sim/tools/jsm/save_form_answers.ts create mode 100644 apps/sim/tools/jsm/submit_form.ts diff --git a/apps/docs/content/docs/en/tools/jira_service_management.mdx b/apps/docs/content/docs/en/tools/jira_service_management.mdx index 5912af4ad1..6b73750e17 100644 --- a/apps/docs/content/docs/en/tools/jira_service_management.mdx +++ b/apps/docs/content/docs/en/tools/jira_service_management.mdx @@ -758,4 +758,235 @@ List forms (ProForma/JSM Forms) attached to a Jira issue with metadata (name, su | ↳ `formTemplateId` | string | Source form template ID \(UUID\) | | `total` | number | Total number of forms | +### `jsm_attach_form` + +Attach a form template to an existing Jira issue or JSM request + +#### 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 to attach the form to \(e.g., "SD-123"\) | +| `formTemplateId` | string | Yes | Form template UUID \(from Get Form Templates\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `issueIdOrKey` | string | Issue ID or key | +| `id` | string | Attached form instance ID \(UUID\) | +| `name` | string | Form name | +| `updated` | string | Last updated timestamp | +| `submitted` | boolean | Whether the form has been submitted | +| `lock` | boolean | Whether the form is locked | +| `internal` | boolean | Whether the form is internal only | +| `formTemplateId` | string | Form template ID | + +### `jsm_save_form_answers` + +Save answers to a form attached to a Jira issue or JSM request + +#### 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"\) | +| `formId` | string | Yes | Form instance UUID \(from Attach Form or Get Issue Forms\) | +| `answers` | json | Yes | Form answers using numeric question IDs as keys \(e.g., \{"1": \{"text": "Title"\}, "4": \{"choices": \["5"\]\}\}\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `issueIdOrKey` | string | Issue ID or key | +| `formId` | string | Form instance UUID | +| `state` | json | Form state with status \(open, submitted, locked\) | +| `updated` | string | Last updated timestamp | + +### `jsm_submit_form` + +Submit a form on a Jira issue or JSM request, locking it from further edits + +#### 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"\) | +| `formId` | string | Yes | Form instance UUID \(from Attach Form or Get Issue Forms\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `issueIdOrKey` | string | Issue ID or key | +| `formId` | string | Form instance UUID | +| `status` | string | Form status after submission \(open, submitted, locked\) | + +### `jsm_get_form` + +Get a single form with full design, state, and answers from a Jira issue + +#### 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"\) | +| `formId` | string | Yes | Form instance UUID \(from Attach Form or Get Issue Forms\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `issueIdOrKey` | string | Issue ID or key | +| `formId` | string | Form instance UUID | +| `design` | json | Full form design with questions, layout, conditions, sections, settings | +| `state` | json | Form state with answers map, status \(o=open, s=submitted, l=locked\), visibility \(i=internal, e=external\) | +| `updated` | string | Last updated timestamp | + +### `jsm_get_form_answers` + +Get simplified answers from a form attached to a Jira issue or JSM request + +#### 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"\) | +| `formId` | string | Yes | Form instance UUID \(from Attach Form or Get Issue Forms\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `issueIdOrKey` | string | Issue ID or key | +| `formId` | string | Form instance UUID | +| `answers` | json | Simplified form answers as key-value pairs \(question label to answer text/choices\) | + +### `jsm_reopen_form` + +Reopen a submitted form on a Jira issue or JSM request, allowing further edits + +#### 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"\) | +| `formId` | string | Yes | Form instance UUID \(from Get Issue Forms\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `issueIdOrKey` | string | Issue ID or key | +| `formId` | string | Form instance UUID | +| `status` | string | Form status after reopening \(open, submitted, locked\) | + +### `jsm_delete_form` + +Remove a form from a Jira issue or JSM request + +#### 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"\) | +| `formId` | string | Yes | Form instance UUID to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `issueIdOrKey` | string | Issue ID or key | +| `formId` | string | Deleted form instance UUID | +| `deleted` | boolean | Whether the form was successfully deleted | + +### `jsm_externalise_form` + +Make a form visible to customers on a Jira issue or JSM request + +#### 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"\) | +| `formId` | string | Yes | Form instance UUID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `issueIdOrKey` | string | Issue ID or key | +| `formId` | string | Form instance UUID | +| `visibility` | string | Form visibility after change \(internal or external\) | + +### `jsm_internalise_form` + +Make a form internal only (not visible to customers) on a Jira issue or JSM request + +#### 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"\) | +| `formId` | string | Yes | Form instance UUID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `issueIdOrKey` | string | Issue ID or key | +| `formId` | string | Form instance UUID | +| `visibility` | string | Form visibility after change \(internal or external\) | + +### `jsm_copy_forms` + +Copy forms from one Jira issue to another + +#### 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 | +| `sourceIssueIdOrKey` | string | Yes | Source issue ID or key to copy forms from \(e.g., "SD-123"\) | +| `targetIssueIdOrKey` | string | Yes | Target issue ID or key to copy forms to \(e.g., "SD-456"\) | +| `formIds` | json | No | Optional JSON array of form UUIDs to copy \(e.g., \["uuid1", "uuid2"\]\). If omitted, copies all forms. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | Timestamp of the operation | +| `sourceIssueIdOrKey` | string | Source issue ID or key | +| `targetIssueIdOrKey` | string | Target issue ID or key | +| `copiedForms` | json | Array of successfully copied forms | +| `errors` | json | Array of errors encountered during copy | + diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 1903537dc1..ec437abe8d 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -6868,9 +6868,49 @@ { "name": "Get Issue Forms", "description": "List forms (ProForma/JSM Forms) attached to a Jira issue with metadata (name, submitted status, lock)" + }, + { + "name": "Attach Form", + "description": "Attach a form template to an existing Jira issue or JSM request" + }, + { + "name": "Save Form Answers", + "description": "Save answers to a form attached to a Jira issue or JSM request" + }, + { + "name": "Submit Form", + "description": "Submit a form on a Jira issue or JSM request, locking it from further edits" + }, + { + "name": "Get Form", + "description": "Get a single form with full design, state, and answers from a Jira issue" + }, + { + "name": "Get Form Answers", + "description": "Get simplified answers from a form attached to a Jira issue or JSM request" + }, + { + "name": "Reopen Form", + "description": "Reopen a submitted form on a Jira issue or JSM request, allowing further edits" + }, + { + "name": "Delete Form", + "description": "Remove a form from a Jira issue or JSM request" + }, + { + "name": "Externalise Form", + "description": "Make a form visible to customers on a Jira issue or JSM request" + }, + { + "name": "Internalise Form", + "description": "Make a form internal only (not visible to customers) on a Jira issue or JSM request" + }, + { + "name": "Copy Forms", + "description": "Copy forms from one Jira issue to another" } ], - "operationCount": 24, + "operationCount": 34, "triggers": [], "triggerCount": 0, "authType": "oauth", diff --git a/apps/sim/app/api/tools/jsm/forms/answers/route.ts b/apps/sim/app/api/tools/jsm/forms/answers/route.ts new file mode 100644 index 0000000000..d7b079df83 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/forms/answers/route.ts @@ -0,0 +1,111 @@ +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, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmGetFormAnswersAPI') + +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, formId } = 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 (!formId) { + logger.error('Missing formId in request') + return NextResponse.json({ error: 'Form ID 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 issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey') + if (!issueIdOrKeyValidation.isValid) { + return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 }) + } + + const formIdValidation = validateJiraCloudId(formId, 'formId') + if (!formIdValidation.isValid) { + return NextResponse.json({ error: formIdValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmFormsApiBaseUrl(cloudId) + const url = `${baseUrl}/issue/${encodeURIComponent(issueIdOrKey)}/form/${encodeURIComponent(formId)}/format/answers` + + logger.info('Getting form answers:', { url, issueIdOrKey, formId }) + + const response = await fetch(url, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM Forms API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + issueIdOrKey, + formId, + answers: data ?? null, + }, + }) + } catch (error) { + logger.error('Error getting form answers:', { + 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 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/forms/attach/route.ts b/apps/sim/app/api/tools/jsm/forms/attach/route.ts new file mode 100644 index 0000000000..a2f5806333 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/forms/attach/route.ts @@ -0,0 +1,114 @@ +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, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmAttachFormAPI') + +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, formTemplateId } = 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 (!formTemplateId) { + logger.error('Missing formTemplateId in request') + return NextResponse.json({ error: 'Form template ID 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 issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey') + if (!issueIdOrKeyValidation.isValid) { + return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmFormsApiBaseUrl(cloudId) + const url = `${baseUrl}/issue/${encodeURIComponent(issueIdOrKey)}/form` + + logger.info('Attaching form to issue:', { url, issueIdOrKey, formTemplateId }) + + const response = await fetch(url, { + method: 'POST', + headers: getJsmHeaders(accessToken), + body: JSON.stringify({ + formTemplate: { id: formTemplateId }, + }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM Forms API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + issueIdOrKey, + id: data.id ?? null, + name: data.name ?? null, + updated: data.updated ?? null, + submitted: data.submitted ?? false, + lock: data.lock ?? false, + internal: data.internal ?? null, + formTemplateId: (data.formTemplate as Record)?.id ?? null, + }, + }) + } catch (error) { + logger.error('Error attaching form:', { + 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 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/forms/copy/route.ts b/apps/sim/app/api/tools/jsm/forms/copy/route.ts new file mode 100644 index 0000000000..bda48e394a --- /dev/null +++ b/apps/sim/app/api/tools/jsm/forms/copy/route.ts @@ -0,0 +1,122 @@ +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, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmCopyFormsAPI') + +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, + sourceIssueIdOrKey, + targetIssueIdOrKey, + formIds, + } = 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 (!sourceIssueIdOrKey) { + logger.error('Missing sourceIssueIdOrKey in request') + return NextResponse.json({ error: 'Source issue ID or key is required' }, { status: 400 }) + } + + if (!targetIssueIdOrKey) { + logger.error('Missing targetIssueIdOrKey in request') + return NextResponse.json({ error: 'Target issue ID or key 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 sourceValidation = validateJiraIssueKey(sourceIssueIdOrKey, 'sourceIssueIdOrKey') + if (!sourceValidation.isValid) { + return NextResponse.json({ error: sourceValidation.error }, { status: 400 }) + } + + const targetValidation = validateJiraIssueKey(targetIssueIdOrKey, 'targetIssueIdOrKey') + if (!targetValidation.isValid) { + return NextResponse.json({ error: targetValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmFormsApiBaseUrl(cloudId) + const url = `${baseUrl}/issue/${encodeURIComponent(sourceIssueIdOrKey)}/form/copy/${encodeURIComponent(targetIssueIdOrKey)}` + + const requestBody = Array.isArray(formIds) && formIds.length > 0 ? { ids: formIds } : {} + + logger.info('Copying forms:', { url, sourceIssueIdOrKey, targetIssueIdOrKey, formIds }) + + const response = await fetch(url, { + method: 'POST', + headers: getJsmHeaders(accessToken), + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM Forms API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + sourceIssueIdOrKey, + targetIssueIdOrKey, + copiedForms: data.copiedForms ?? [], + errors: data.errors ?? [], + }, + }) + } catch (error) { + logger.error('Error copying forms:', { + 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 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/forms/delete/route.ts b/apps/sim/app/api/tools/jsm/forms/delete/route.ts new file mode 100644 index 0000000000..d5942c4d76 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/forms/delete/route.ts @@ -0,0 +1,111 @@ +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, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmDeleteFormAPI') + +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, formId } = 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 (!formId) { + logger.error('Missing formId in request') + return NextResponse.json({ error: 'Form ID 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 issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey') + if (!issueIdOrKeyValidation.isValid) { + return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 }) + } + + const formIdValidation = validateJiraCloudId(formId, 'formId') + if (!formIdValidation.isValid) { + return NextResponse.json({ error: formIdValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmFormsApiBaseUrl(cloudId) + const url = `${baseUrl}/issue/${encodeURIComponent(issueIdOrKey)}/form/${encodeURIComponent(formId)}` + + logger.info('Deleting form:', { url, issueIdOrKey, formId }) + + const response = await fetch(url, { + method: 'DELETE', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM Forms API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + await response.text() + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + issueIdOrKey, + formId, + deleted: true, + }, + }) + } catch (error) { + logger.error('Error deleting form:', { + 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 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/forms/externalise/route.ts b/apps/sim/app/api/tools/jsm/forms/externalise/route.ts new file mode 100644 index 0000000000..6d0e8b57a6 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/forms/externalise/route.ts @@ -0,0 +1,111 @@ +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, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmExternaliseFormAPI') + +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, formId } = 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 (!formId) { + logger.error('Missing formId in request') + return NextResponse.json({ error: 'Form ID 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 issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey') + if (!issueIdOrKeyValidation.isValid) { + return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 }) + } + + const formIdValidation = validateJiraCloudId(formId, 'formId') + if (!formIdValidation.isValid) { + return NextResponse.json({ error: formIdValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmFormsApiBaseUrl(cloudId) + const url = `${baseUrl}/issue/${encodeURIComponent(issueIdOrKey)}/form/${encodeURIComponent(formId)}/action/external` + + logger.info('Externalising form:', { url, issueIdOrKey, formId }) + + const response = await fetch(url, { + method: 'PUT', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM Forms API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + issueIdOrKey, + formId, + visibility: data.visibility ?? 'external', + }, + }) + } catch (error) { + logger.error('Error externalising form:', { + 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 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/forms/get/route.ts b/apps/sim/app/api/tools/jsm/forms/get/route.ts new file mode 100644 index 0000000000..d73cf9c4c5 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/forms/get/route.ts @@ -0,0 +1,113 @@ +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, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmGetFormAPI') + +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, formId } = 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 (!formId) { + logger.error('Missing formId in request') + return NextResponse.json({ error: 'Form ID 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 issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey') + if (!issueIdOrKeyValidation.isValid) { + return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 }) + } + + const formIdValidation = validateJiraCloudId(formId, 'formId') + if (!formIdValidation.isValid) { + return NextResponse.json({ error: formIdValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmFormsApiBaseUrl(cloudId) + const url = `${baseUrl}/issue/${encodeURIComponent(issueIdOrKey)}/form/${encodeURIComponent(formId)}` + + logger.info('Getting form:', { url, issueIdOrKey, formId }) + + const response = await fetch(url, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM Forms API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + issueIdOrKey, + formId, + design: data.design ?? null, + state: data.state ?? null, + updated: data.updated ?? null, + }, + }) + } catch (error) { + logger.error('Error getting form:', { + 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 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/forms/internalise/route.ts b/apps/sim/app/api/tools/jsm/forms/internalise/route.ts new file mode 100644 index 0000000000..8d821ced59 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/forms/internalise/route.ts @@ -0,0 +1,111 @@ +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, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmInternaliseFormAPI') + +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, formId } = 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 (!formId) { + logger.error('Missing formId in request') + return NextResponse.json({ error: 'Form ID 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 issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey') + if (!issueIdOrKeyValidation.isValid) { + return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 }) + } + + const formIdValidation = validateJiraCloudId(formId, 'formId') + if (!formIdValidation.isValid) { + return NextResponse.json({ error: formIdValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmFormsApiBaseUrl(cloudId) + const url = `${baseUrl}/issue/${encodeURIComponent(issueIdOrKey)}/form/${encodeURIComponent(formId)}/action/internal` + + logger.info('Internalising form:', { url, issueIdOrKey, formId }) + + const response = await fetch(url, { + method: 'PUT', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM Forms API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + issueIdOrKey, + formId, + visibility: data.visibility ?? 'internal', + }, + }) + } catch (error) { + logger.error('Error internalising form:', { + 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 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/forms/reopen/route.ts b/apps/sim/app/api/tools/jsm/forms/reopen/route.ts new file mode 100644 index 0000000000..df9a452a02 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/forms/reopen/route.ts @@ -0,0 +1,111 @@ +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, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmReopenFormAPI') + +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, formId } = 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 (!formId) { + logger.error('Missing formId in request') + return NextResponse.json({ error: 'Form ID 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 issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey') + if (!issueIdOrKeyValidation.isValid) { + return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 }) + } + + const formIdValidation = validateJiraCloudId(formId, 'formId') + if (!formIdValidation.isValid) { + return NextResponse.json({ error: formIdValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmFormsApiBaseUrl(cloudId) + const url = `${baseUrl}/issue/${encodeURIComponent(issueIdOrKey)}/form/${encodeURIComponent(formId)}/action/reopen` + + logger.info('Reopening form:', { url, issueIdOrKey, formId }) + + const response = await fetch(url, { + method: 'PUT', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM Forms API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + issueIdOrKey, + formId, + status: data.status ?? 'open', + }, + }) + } catch (error) { + logger.error('Error reopening form:', { + 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 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/forms/save/route.ts b/apps/sim/app/api/tools/jsm/forms/save/route.ts new file mode 100644 index 0000000000..6e4f8724b0 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/forms/save/route.ts @@ -0,0 +1,118 @@ +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, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmSaveFormAnswersAPI') + +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, formId, answers } = 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 (!formId) { + logger.error('Missing formId in request') + return NextResponse.json({ error: 'Form ID is required' }, { status: 400 }) + } + + if (!answers || typeof answers !== 'object') { + logger.error('Missing or invalid answers in request') + return NextResponse.json({ error: 'Answers object 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 issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey') + if (!issueIdOrKeyValidation.isValid) { + return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 }) + } + + const formIdValidation = validateJiraCloudId(formId, 'formId') + if (!formIdValidation.isValid) { + return NextResponse.json({ error: formIdValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmFormsApiBaseUrl(cloudId) + const url = `${baseUrl}/issue/${encodeURIComponent(issueIdOrKey)}/form/${encodeURIComponent(formId)}` + + logger.info('Saving form answers:', { url, issueIdOrKey, formId }) + + const response = await fetch(url, { + method: 'PUT', + headers: getJsmHeaders(accessToken), + body: JSON.stringify({ answers }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM Forms API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + issueIdOrKey, + formId, + state: data.state ?? null, + updated: data.updated ?? null, + }, + }) + } catch (error) { + logger.error('Error saving form answers:', { + 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 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/forms/submit/route.ts b/apps/sim/app/api/tools/jsm/forms/submit/route.ts new file mode 100644 index 0000000000..048d06363c --- /dev/null +++ b/apps/sim/app/api/tools/jsm/forms/submit/route.ts @@ -0,0 +1,111 @@ +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, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('JsmSubmitFormAPI') + +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, formId } = 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 (!formId) { + logger.error('Missing formId in request') + return NextResponse.json({ error: 'Form ID 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 issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey') + if (!issueIdOrKeyValidation.isValid) { + return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 }) + } + + const formIdValidation = validateJiraCloudId(formId, 'formId') + if (!formIdValidation.isValid) { + return NextResponse.json({ error: formIdValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmFormsApiBaseUrl(cloudId) + const url = `${baseUrl}/issue/${encodeURIComponent(issueIdOrKey)}/form/${encodeURIComponent(formId)}/action/submit` + + logger.info('Submitting form:', { url, issueIdOrKey, formId }) + + const response = await fetch(url, { + method: 'PUT', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM Forms API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + + return NextResponse.json( + { + error: parseAtlassianErrorMessage(response.status, response.statusText, errorText), + details: errorText, + }, + { status: response.status } + ) + } + + const data = await response.json() + + return NextResponse.json({ + success: true, + output: { + ts: new Date().toISOString(), + issueIdOrKey, + formId, + status: data.status ?? 'submitted', + }, + }) + } catch (error) { + logger.error('Error submitting form:', { + 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 } + ) + } +} diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index 8699154417..3416192814 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -47,6 +47,16 @@ export const JiraServiceManagementBlock: BlockConfig = { { label: 'Get Form Templates', id: 'get_form_templates' }, { label: 'Get Form Structure', id: 'get_form_structure' }, { label: 'Get Issue Forms', id: 'get_issue_forms' }, + { label: 'Attach Form', id: 'attach_form' }, + { label: 'Save Form Answers', id: 'save_form_answers' }, + { label: 'Submit Form', id: 'submit_form' }, + { label: 'Get Form', id: 'get_form' }, + { label: 'Get Form Answers', id: 'get_form_answers' }, + { label: 'Reopen Form', id: 'reopen_form' }, + { label: 'Delete Form', id: 'delete_form' }, + { label: 'Externalise Form', id: 'externalise_form' }, + { label: 'Internalise Form', id: 'internalise_form' }, + { label: 'Copy Forms', id: 'copy_forms' }, ], value: () => 'get_service_desks', }, @@ -195,6 +205,15 @@ export const JiraServiceManagementBlock: BlockConfig = { 'get_approvals', 'answer_approval', 'get_issue_forms', + 'attach_form', + 'save_form_answers', + 'submit_form', + 'get_form', + 'get_form_answers', + 'reopen_form', + 'delete_form', + 'externalise_form', + 'internalise_form', ], }, }, @@ -211,8 +230,54 @@ export const JiraServiceManagementBlock: BlockConfig = { title: 'Form ID', type: 'short-input', required: true, - placeholder: 'Enter form ID (UUID from Get Form Templates)', - condition: { field: 'operation', value: 'get_form_structure' }, + placeholder: 'Enter form ID (UUID from Get Form Templates or Attach Form)', + condition: { + field: 'operation', + value: [ + 'get_form_structure', + 'save_form_answers', + 'submit_form', + 'get_form', + 'get_form_answers', + 'reopen_form', + 'delete_form', + 'externalise_form', + 'internalise_form', + ], + }, + }, + { + id: 'formTemplateId', + title: 'Form Template ID', + type: 'short-input', + required: true, + placeholder: 'Enter form template UUID (from Get Form Templates)', + condition: { field: 'operation', value: 'attach_form' }, + }, + { + id: 'sourceIssueIdOrKey', + title: 'Source Issue ID or Key', + type: 'short-input', + required: true, + placeholder: 'Issue to copy forms from (e.g., SD-123)', + condition: { field: 'operation', value: 'copy_forms' }, + }, + { + id: 'targetIssueIdOrKey', + title: 'Target Issue ID or Key', + type: 'short-input', + required: true, + placeholder: 'Issue to copy forms to (e.g., SD-456)', + condition: { field: 'operation', value: 'copy_forms' }, + }, + { + id: 'formIds', + title: 'Form IDs to Copy', + type: 'long-input', + placeholder: + 'JSON array of form UUIDs (e.g., ["uuid1", "uuid2"]). Leave empty to copy all forms.', + mode: 'advanced', + condition: { field: 'operation', value: 'copy_forms' }, }, { id: 'summary', @@ -292,7 +357,7 @@ Return ONLY the description text - no explanations.`, placeholder: 'JSON object using form question IDs as keys (e.g., {"1": {"text": "Title"}, "4": {"choices": ["5"]}, "14": {"text": "Details"}})', mode: 'advanced', - condition: { field: 'operation', value: 'create_request' }, + condition: { field: 'operation', value: ['create_request', 'save_form_answers'] }, }, { id: 'searchQuery', @@ -526,6 +591,16 @@ Return ONLY the comment text - no explanations.`, 'jsm_get_form_templates', 'jsm_get_form_structure', 'jsm_get_issue_forms', + 'jsm_attach_form', + 'jsm_save_form_answers', + 'jsm_submit_form', + 'jsm_get_form', + 'jsm_get_form_answers', + 'jsm_reopen_form', + 'jsm_delete_form', + 'jsm_externalise_form', + 'jsm_internalise_form', + 'jsm_copy_forms', ], config: { tool: (params) => { @@ -578,6 +653,26 @@ Return ONLY the comment text - no explanations.`, return 'jsm_get_form_structure' case 'get_issue_forms': return 'jsm_get_issue_forms' + case 'attach_form': + return 'jsm_attach_form' + case 'save_form_answers': + return 'jsm_save_form_answers' + case 'submit_form': + return 'jsm_submit_form' + case 'get_form': + return 'jsm_get_form' + case 'get_form_answers': + return 'jsm_get_form_answers' + case 'reopen_form': + return 'jsm_reopen_form' + case 'delete_form': + return 'jsm_delete_form' + case 'externalise_form': + return 'jsm_externalise_form' + case 'internalise_form': + return 'jsm_internalise_form' + case 'copy_forms': + return 'jsm_copy_forms' default: return 'jsm_get_service_desks' } @@ -865,6 +960,145 @@ Return ONLY the comment text - no explanations.`, ...baseParams, issueIdOrKey: params.issueIdOrKey, } + case 'attach_form': + if (!params.issueIdOrKey) { + throw new Error('Issue ID or key is required') + } + if (!params.formTemplateId) { + throw new Error('Form template ID is required') + } + return { + ...baseParams, + issueIdOrKey: params.issueIdOrKey, + formTemplateId: params.formTemplateId, + } + case 'save_form_answers': + if (!params.issueIdOrKey) { + throw new Error('Issue ID or key is required') + } + if (!params.formId) { + throw new Error('Form ID is required') + } + if (!params.formAnswers) { + throw new Error('Form answers are required') + } + return { + ...baseParams, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + answers: (() => { + try { + return JSON.parse(params.formAnswers) + } catch { + throw new Error('formAnswers must be valid JSON') + } + })(), + } + case 'submit_form': + if (!params.issueIdOrKey) { + throw new Error('Issue ID or key is required') + } + if (!params.formId) { + throw new Error('Form ID is required') + } + return { + ...baseParams, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + } + case 'get_form_answers': + if (!params.issueIdOrKey) { + throw new Error('Issue ID or key is required') + } + if (!params.formId) { + throw new Error('Form ID is required') + } + return { + ...baseParams, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + } + case 'reopen_form': + if (!params.issueIdOrKey) { + throw new Error('Issue ID or key is required') + } + if (!params.formId) { + throw new Error('Form ID is required') + } + return { + ...baseParams, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + } + case 'get_form': + if (!params.issueIdOrKey) { + throw new Error('Issue ID or key is required') + } + if (!params.formId) { + throw new Error('Form ID is required') + } + return { + ...baseParams, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + } + case 'delete_form': + if (!params.issueIdOrKey) { + throw new Error('Issue ID or key is required') + } + if (!params.formId) { + throw new Error('Form ID is required') + } + return { + ...baseParams, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + } + case 'externalise_form': + if (!params.issueIdOrKey) { + throw new Error('Issue ID or key is required') + } + if (!params.formId) { + throw new Error('Form ID is required') + } + return { + ...baseParams, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + } + case 'internalise_form': + if (!params.issueIdOrKey) { + throw new Error('Issue ID or key is required') + } + if (!params.formId) { + throw new Error('Form ID is required') + } + return { + ...baseParams, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + } + case 'copy_forms': + if (!params.sourceIssueIdOrKey) { + throw new Error('Source issue ID or key is required') + } + if (!params.targetIssueIdOrKey) { + throw new Error('Target issue ID or key is required') + } + return { + ...baseParams, + sourceIssueIdOrKey: params.sourceIssueIdOrKey, + targetIssueIdOrKey: params.targetIssueIdOrKey, + formIds: params.formIds + ? (() => { + try { + return JSON.parse(params.formIds) + } catch { + throw new Error('formIds must be valid JSON array') + } + })() + : undefined, + } default: return baseParams } @@ -916,6 +1150,10 @@ Return ONLY the comment text - no explanations.`, }, projectIdOrKey: { type: 'string', description: 'Jira project ID or key' }, formId: { type: 'string', description: 'Form ID (UUID)' }, + formTemplateId: { type: 'string', description: 'Form template UUID' }, + sourceIssueIdOrKey: { type: 'string', description: 'Source issue ID or key for copy' }, + targetIssueIdOrKey: { type: 'string', description: 'Target issue ID or key for copy' }, + formIds: { type: 'string', description: 'JSON array of form UUIDs to copy' }, searchQuery: { type: 'string', description: 'Filter request types by name' }, groupId: { type: 'string', description: 'Filter by request type group ID' }, expand: { type: 'string', description: 'Comma-separated fields to expand' }, @@ -978,5 +1216,28 @@ Return ONLY the comment text - no explanations.`, description: 'Array of forms attached to an issue (id, name, updated, submitted, lock, internal, formTemplateId)', }, + formId: { type: 'string', description: 'Form instance UUID' }, + formTemplateId: { type: 'string', description: 'Form template ID' }, + submitted: { type: 'boolean', description: 'Whether the form has been submitted' }, + lock: { type: 'boolean', description: 'Whether the form is locked' }, + internal: { type: 'boolean', description: 'Whether the form is internal only' }, + state: { + type: 'json', + description: 'Form state with status (open, submitted, locked)', + }, + status: { type: 'string', description: 'Form status (open, submitted, locked)' }, + answers: { + type: 'json', + description: 'Form answers as key-value pairs (question ID to answer)', + }, + deleted: { type: 'boolean', description: 'Whether the form was successfully deleted' }, + visibility: { + type: 'string', + description: 'Form visibility (internal or external)', + }, + copiedForms: { type: 'json', description: 'Array of successfully copied forms' }, + errors: { type: 'json', description: 'Array of errors from copy forms operation' }, + sourceIssueIdOrKey: { type: 'string', description: 'Source issue ID or key' }, + targetIssueIdOrKey: { type: 'string', description: 'Target issue ID or key' }, }, } diff --git a/apps/sim/tools/jsm/attach_form.ts b/apps/sim/tools/jsm/attach_form.ts new file mode 100644 index 0000000000..4596756f40 --- /dev/null +++ b/apps/sim/tools/jsm/attach_form.ts @@ -0,0 +1,122 @@ +import type { JsmAttachFormParams, JsmAttachFormResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmAttachFormTool: ToolConfig = { + id: 'jsm_attach_form', + name: 'JSM Attach Form', + description: 'Attach a form template to an existing Jira issue or JSM request', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + issueIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Issue ID or key to attach the form to (e.g., "SD-123")', + }, + formTemplateId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Form template UUID (from Get Form Templates)', + }, + }, + + request: { + url: '/api/tools/jsm/forms/attach', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + issueIdOrKey: params.issueIdOrKey, + formTemplateId: params.formTemplateId, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { + ts: new Date().toISOString(), + issueIdOrKey: '', + id: '', + name: '', + updated: null, + submitted: false, + lock: false, + internal: null, + formTemplateId: null, + }, + error: 'Empty response from API', + } + } + + const data = JSON.parse(responseText) + + if (data.success && data.output) { + return data + } + + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + issueIdOrKey: '', + id: '', + name: '', + updated: null, + submitted: false, + lock: false, + internal: null, + formTemplateId: null, + }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + issueIdOrKey: { type: 'string', description: 'Issue ID or key' }, + id: { type: 'string', description: 'Attached form instance ID (UUID)' }, + name: { type: 'string', description: 'Form name' }, + updated: { type: 'string', description: 'Last updated timestamp', optional: true }, + submitted: { type: 'boolean', description: 'Whether the form has been submitted' }, + lock: { type: 'boolean', description: 'Whether the form is locked' }, + internal: { type: 'boolean', description: 'Whether the form is internal only', optional: true }, + formTemplateId: { + type: 'string', + description: 'Form template ID', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/jsm/copy_forms.ts b/apps/sim/tools/jsm/copy_forms.ts new file mode 100644 index 0000000000..a894fc8d8d --- /dev/null +++ b/apps/sim/tools/jsm/copy_forms.ts @@ -0,0 +1,98 @@ +import type { JsmCopyFormsParams, JsmCopyFormsResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmCopyFormsTool: ToolConfig = { + id: 'jsm_copy_forms', + name: 'JSM Copy Forms', + description: 'Copy forms from one Jira issue to another', + version: '1.0.0', + oauth: { required: true, provider: 'jira' }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + sourceIssueIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Source issue ID or key to copy forms from (e.g., "SD-123")', + }, + targetIssueIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Target issue ID or key to copy forms to (e.g., "SD-456")', + }, + formIds: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Optional JSON array of form UUIDs to copy (e.g., ["uuid1", "uuid2"]). If omitted, copies all forms.', + }, + }, + request: { + url: '/api/tools/jsm/forms/copy', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + sourceIssueIdOrKey: params.sourceIssueIdOrKey, + targetIssueIdOrKey: params.targetIssueIdOrKey, + formIds: params.formIds, + }), + }, + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { + ts: new Date().toISOString(), + sourceIssueIdOrKey: '', + targetIssueIdOrKey: '', + copiedForms: [], + errors: [], + }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + sourceIssueIdOrKey: '', + targetIssueIdOrKey: '', + copiedForms: [], + errors: [], + }, + error: data.error, + } + }, + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + sourceIssueIdOrKey: { type: 'string', description: 'Source issue ID or key' }, + targetIssueIdOrKey: { type: 'string', description: 'Target issue ID or key' }, + copiedForms: { type: 'json', description: 'Array of successfully copied forms' }, + errors: { type: 'json', description: 'Array of errors encountered during copy' }, + }, +} diff --git a/apps/sim/tools/jsm/delete_form.ts b/apps/sim/tools/jsm/delete_form.ts new file mode 100644 index 0000000000..9555fffe7d --- /dev/null +++ b/apps/sim/tools/jsm/delete_form.ts @@ -0,0 +1,82 @@ +import type { JsmDeleteFormParams, JsmDeleteFormResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmDeleteFormTool: ToolConfig = { + id: 'jsm_delete_form', + name: 'JSM Delete Form', + description: 'Remove a form from a Jira issue or JSM request', + version: '1.0.0', + oauth: { required: true, provider: 'jira' }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + issueIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Issue ID or key (e.g., "SD-123")', + }, + formId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Form instance UUID to delete', + }, + }, + request: { + url: '/api/tools/jsm/forms/delete', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + }), + }, + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), issueIdOrKey: '', formId: '', deleted: false }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + issueIdOrKey: '', + formId: '', + deleted: false, + }, + error: data.error, + } + }, + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + issueIdOrKey: { type: 'string', description: 'Issue ID or key' }, + formId: { type: 'string', description: 'Deleted form instance UUID' }, + deleted: { type: 'boolean', description: 'Whether the form was successfully deleted' }, + }, +} diff --git a/apps/sim/tools/jsm/externalise_form.ts b/apps/sim/tools/jsm/externalise_form.ts new file mode 100644 index 0000000000..05c0a2ada7 --- /dev/null +++ b/apps/sim/tools/jsm/externalise_form.ts @@ -0,0 +1,88 @@ +import type { JsmExternaliseFormParams, JsmExternaliseFormResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmExternaliseFormTool: ToolConfig< + JsmExternaliseFormParams, + JsmExternaliseFormResponse +> = { + id: 'jsm_externalise_form', + name: 'JSM Externalise Form', + description: 'Make a form visible to customers on a Jira issue or JSM request', + version: '1.0.0', + oauth: { required: true, provider: 'jira' }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + issueIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Issue ID or key (e.g., "SD-123")', + }, + formId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Form instance UUID', + }, + }, + request: { + url: '/api/tools/jsm/forms/externalise', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + }), + }, + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), issueIdOrKey: '', formId: '', visibility: '' }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + issueIdOrKey: '', + formId: '', + visibility: '', + }, + error: data.error, + } + }, + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + issueIdOrKey: { type: 'string', description: 'Issue ID or key' }, + formId: { type: 'string', description: 'Form instance UUID' }, + visibility: { + type: 'string', + description: 'Form visibility after change (internal or external)', + }, + }, +} diff --git a/apps/sim/tools/jsm/get_form.ts b/apps/sim/tools/jsm/get_form.ts new file mode 100644 index 0000000000..325ab8c80e --- /dev/null +++ b/apps/sim/tools/jsm/get_form.ts @@ -0,0 +1,122 @@ +import type { JsmGetFormParams, JsmGetFormResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmGetFormTool: ToolConfig = { + id: 'jsm_get_form', + name: 'JSM Get Form', + description: 'Get a single form with full design, state, and answers from a Jira issue', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + issueIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Issue ID or key (e.g., "SD-123")', + }, + formId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Form instance UUID (from Attach Form or Get Issue Forms)', + }, + }, + + request: { + url: '/api/tools/jsm/forms/get', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { + ts: new Date().toISOString(), + issueIdOrKey: '', + formId: '', + design: null, + state: null, + updated: null, + }, + error: 'Empty response from API', + } + } + + const data = JSON.parse(responseText) + + if (data.success && data.output) { + return data + } + + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + issueIdOrKey: '', + formId: '', + design: null, + state: null, + updated: null, + }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + issueIdOrKey: { type: 'string', description: 'Issue ID or key' }, + formId: { type: 'string', description: 'Form instance UUID' }, + design: { + type: 'json', + description: 'Full form design with questions, layout, conditions, sections, settings', + optional: true, + }, + state: { + type: 'json', + description: + 'Form state with answers map, status (o=open, s=submitted, l=locked), visibility (i=internal, e=external)', + optional: true, + }, + updated: { + type: 'string', + description: 'Last updated timestamp', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/jsm/get_form_answers.ts b/apps/sim/tools/jsm/get_form_answers.ts new file mode 100644 index 0000000000..c445e0b5f7 --- /dev/null +++ b/apps/sim/tools/jsm/get_form_answers.ts @@ -0,0 +1,109 @@ +import type { JsmGetFormAnswersParams, JsmGetFormAnswersResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmGetFormAnswersTool: ToolConfig = + { + id: 'jsm_get_form_answers', + name: 'JSM Get Form Answers', + description: 'Get simplified answers from a form attached to a Jira issue or JSM request', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + issueIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Issue ID or key (e.g., "SD-123")', + }, + formId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Form instance UUID (from Attach Form or Get Issue Forms)', + }, + }, + + request: { + url: '/api/tools/jsm/forms/answers', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { + ts: new Date().toISOString(), + issueIdOrKey: '', + formId: '', + answers: null, + }, + error: 'Empty response from API', + } + } + + const data = JSON.parse(responseText) + + if (data.success && data.output) { + return data + } + + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + issueIdOrKey: '', + formId: '', + answers: null, + }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + issueIdOrKey: { type: 'string', description: 'Issue ID or key' }, + formId: { type: 'string', description: 'Form instance UUID' }, + answers: { + type: 'json', + description: + 'Simplified form answers as key-value pairs (question label to answer text/choices)', + optional: true, + }, + }, + } diff --git a/apps/sim/tools/jsm/index.ts b/apps/sim/tools/jsm/index.ts index 8cf000e470..e11696f0f3 100644 --- a/apps/sim/tools/jsm/index.ts +++ b/apps/sim/tools/jsm/index.ts @@ -3,11 +3,17 @@ import { jsmAddCustomerTool } from '@/tools/jsm/add_customer' import { jsmAddOrganizationTool } from '@/tools/jsm/add_organization' import { jsmAddParticipantsTool } from '@/tools/jsm/add_participants' import { jsmAnswerApprovalTool } from '@/tools/jsm/answer_approval' +import { jsmAttachFormTool } from '@/tools/jsm/attach_form' +import { jsmCopyFormsTool } from '@/tools/jsm/copy_forms' import { jsmCreateOrganizationTool } from '@/tools/jsm/create_organization' import { jsmCreateRequestTool } from '@/tools/jsm/create_request' +import { jsmDeleteFormTool } from '@/tools/jsm/delete_form' +import { jsmExternaliseFormTool } from '@/tools/jsm/externalise_form' import { jsmGetApprovalsTool } from '@/tools/jsm/get_approvals' import { jsmGetCommentsTool } from '@/tools/jsm/get_comments' import { jsmGetCustomersTool } from '@/tools/jsm/get_customers' +import { jsmGetFormTool } from '@/tools/jsm/get_form' +import { jsmGetFormAnswersTool } from '@/tools/jsm/get_form_answers' import { jsmGetFormStructureTool } from '@/tools/jsm/get_form_structure' import { jsmGetFormTemplatesTool } from '@/tools/jsm/get_form_templates' import { jsmGetIssueFormsTool } from '@/tools/jsm/get_issue_forms' @@ -21,6 +27,10 @@ import { jsmGetRequestsTool } from '@/tools/jsm/get_requests' import { jsmGetServiceDesksTool } from '@/tools/jsm/get_service_desks' import { jsmGetSlaTool } from '@/tools/jsm/get_sla' import { jsmGetTransitionsTool } from '@/tools/jsm/get_transitions' +import { jsmInternaliseFormTool } from '@/tools/jsm/internalise_form' +import { jsmReopenFormTool } from '@/tools/jsm/reopen_form' +import { jsmSaveFormAnswersTool } from '@/tools/jsm/save_form_answers' +import { jsmSubmitFormTool } from '@/tools/jsm/submit_form' import { jsmTransitionRequestTool } from '@/tools/jsm/transition_request' export { @@ -29,11 +39,17 @@ export { jsmAddOrganizationTool, jsmAddParticipantsTool, jsmAnswerApprovalTool, + jsmAttachFormTool, + jsmCopyFormsTool, jsmCreateOrganizationTool, jsmCreateRequestTool, + jsmDeleteFormTool, + jsmExternaliseFormTool, jsmGetApprovalsTool, jsmGetCommentsTool, jsmGetCustomersTool, + jsmGetFormTool, + jsmGetFormAnswersTool, jsmGetFormStructureTool, jsmGetFormTemplatesTool, jsmGetIssueFormsTool, @@ -47,5 +63,9 @@ export { jsmGetServiceDesksTool, jsmGetSlaTool, jsmGetTransitionsTool, + jsmInternaliseFormTool, + jsmReopenFormTool, + jsmSaveFormAnswersTool, + jsmSubmitFormTool, jsmTransitionRequestTool, } diff --git a/apps/sim/tools/jsm/internalise_form.ts b/apps/sim/tools/jsm/internalise_form.ts new file mode 100644 index 0000000000..2683b605cf --- /dev/null +++ b/apps/sim/tools/jsm/internalise_form.ts @@ -0,0 +1,89 @@ +import type { JsmInternaliseFormParams, JsmInternaliseFormResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmInternaliseFormTool: ToolConfig< + JsmInternaliseFormParams, + JsmInternaliseFormResponse +> = { + id: 'jsm_internalise_form', + name: 'JSM Internalise Form', + description: + 'Make a form internal only (not visible to customers) on a Jira issue or JSM request', + version: '1.0.0', + oauth: { required: true, provider: 'jira' }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + issueIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Issue ID or key (e.g., "SD-123")', + }, + formId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Form instance UUID', + }, + }, + request: { + url: '/api/tools/jsm/forms/internalise', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + }), + }, + transformResponse: async (response: Response) => { + const responseText = await response.text() + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), issueIdOrKey: '', formId: '', visibility: '' }, + error: 'Empty response from API', + } + } + const data = JSON.parse(responseText) + if (data.success && data.output) return data + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + issueIdOrKey: '', + formId: '', + visibility: '', + }, + error: data.error, + } + }, + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + issueIdOrKey: { type: 'string', description: 'Issue ID or key' }, + formId: { type: 'string', description: 'Form instance UUID' }, + visibility: { + type: 'string', + description: 'Form visibility after change (internal or external)', + }, + }, +} diff --git a/apps/sim/tools/jsm/reopen_form.ts b/apps/sim/tools/jsm/reopen_form.ts new file mode 100644 index 0000000000..1578524edd --- /dev/null +++ b/apps/sim/tools/jsm/reopen_form.ts @@ -0,0 +1,106 @@ +import type { JsmReopenFormParams, JsmReopenFormResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmReopenFormTool: ToolConfig = { + id: 'jsm_reopen_form', + name: 'JSM Reopen Form', + description: 'Reopen a submitted form on a Jira issue or JSM request, allowing further edits', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + issueIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Issue ID or key (e.g., "SD-123")', + }, + formId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Form instance UUID (from Get Issue Forms)', + }, + }, + + request: { + url: '/api/tools/jsm/forms/reopen', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { + ts: new Date().toISOString(), + issueIdOrKey: '', + formId: '', + status: '', + }, + error: 'Empty response from API', + } + } + + const data = JSON.parse(responseText) + + if (data.success && data.output) { + return data + } + + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + issueIdOrKey: '', + formId: '', + status: '', + }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + issueIdOrKey: { type: 'string', description: 'Issue ID or key' }, + formId: { type: 'string', description: 'Form instance UUID' }, + status: { + type: 'string', + description: 'Form status after reopening (open, submitted, locked)', + }, + }, +} diff --git a/apps/sim/tools/jsm/save_form_answers.ts b/apps/sim/tools/jsm/save_form_answers.ts new file mode 100644 index 0000000000..e20204827a --- /dev/null +++ b/apps/sim/tools/jsm/save_form_answers.ts @@ -0,0 +1,121 @@ +import type { JsmSaveFormAnswersParams, JsmSaveFormAnswersResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmSaveFormAnswersTool: ToolConfig< + JsmSaveFormAnswersParams, + JsmSaveFormAnswersResponse +> = { + id: 'jsm_save_form_answers', + name: 'JSM Save Form Answers', + description: 'Save answers to a form attached to a Jira issue or JSM request', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + issueIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Issue ID or key (e.g., "SD-123")', + }, + formId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Form instance UUID (from Attach Form or Get Issue Forms)', + }, + answers: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Form answers using numeric question IDs as keys (e.g., {"1": {"text": "Title"}, "4": {"choices": ["5"]}})', + }, + }, + + request: { + url: '/api/tools/jsm/forms/save', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + answers: params.answers, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { + ts: new Date().toISOString(), + issueIdOrKey: '', + formId: '', + state: null, + updated: null, + }, + error: 'Empty response from API', + } + } + + const data = JSON.parse(responseText) + + if (data.success && data.output) { + return data + } + + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + issueIdOrKey: '', + formId: '', + state: null, + updated: null, + }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + issueIdOrKey: { type: 'string', description: 'Issue ID or key' }, + formId: { type: 'string', description: 'Form instance UUID' }, + state: { + type: 'json', + description: 'Form state with status (open, submitted, locked)', + optional: true, + }, + updated: { type: 'string', description: 'Last updated timestamp', optional: true }, + }, +} diff --git a/apps/sim/tools/jsm/submit_form.ts b/apps/sim/tools/jsm/submit_form.ts new file mode 100644 index 0000000000..733ef422d2 --- /dev/null +++ b/apps/sim/tools/jsm/submit_form.ts @@ -0,0 +1,106 @@ +import type { JsmSubmitFormParams, JsmSubmitFormResponse } from '@/tools/jsm/types' +import type { ToolConfig } from '@/tools/types' + +export const jsmSubmitFormTool: ToolConfig = { + id: 'jsm_submit_form', + name: 'JSM Submit Form', + description: 'Submit a form on a Jira issue or JSM request, locking it from further edits', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira Service Management', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Jira Cloud ID for the instance', + }, + issueIdOrKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Issue ID or key (e.g., "SD-123")', + }, + formId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Form instance UUID (from Attach Form or Get Issue Forms)', + }, + }, + + request: { + url: '/api/tools/jsm/forms/submit', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + domain: params.domain, + accessToken: params.accessToken, + cloudId: params.cloudId, + issueIdOrKey: params.issueIdOrKey, + formId: params.formId, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { + ts: new Date().toISOString(), + issueIdOrKey: '', + formId: '', + status: '', + }, + error: 'Empty response from API', + } + } + + const data = JSON.parse(responseText) + + if (data.success && data.output) { + return data + } + + return { + success: data.success || false, + output: data.output || { + ts: new Date().toISOString(), + issueIdOrKey: '', + formId: '', + status: '', + }, + error: data.error, + } + }, + + outputs: { + ts: { type: 'string', description: 'Timestamp of the operation' }, + issueIdOrKey: { type: 'string', description: 'Issue ID or key' }, + formId: { type: 'string', description: 'Form instance UUID' }, + status: { + type: 'string', + description: 'Form status after submission (open, submitted, locked)', + }, + }, +} diff --git a/apps/sim/tools/jsm/types.ts b/apps/sim/tools/jsm/types.ts index b76b6dbfdb..6acf999948 100644 --- a/apps/sim/tools/jsm/types.ts +++ b/apps/sim/tools/jsm/types.ts @@ -899,6 +899,201 @@ export interface JsmGetIssueFormsResponse extends ToolResponse { } } +// --------------------------------------------------------------------------- +// Attach Form +// --------------------------------------------------------------------------- + +export interface JsmAttachFormParams extends JsmBaseParams { + issueIdOrKey: string + formTemplateId: string +} + +export interface JsmAttachFormResponse extends ToolResponse { + output: { + ts: string + issueIdOrKey: string + id: string + name: string + updated: string | null + submitted: boolean + lock: boolean + internal: boolean | null + formTemplateId: string | null + } +} + +// --------------------------------------------------------------------------- +// Save Form Answers +// --------------------------------------------------------------------------- + +export interface JsmSaveFormAnswersParams extends JsmBaseParams { + issueIdOrKey: string + formId: string + answers: Record +} + +export interface JsmSaveFormAnswersResponse extends ToolResponse { + output: { + ts: string + issueIdOrKey: string + formId: string + state: { status: string } | null + updated: string | null + } +} + +// --------------------------------------------------------------------------- +// Submit Form +// --------------------------------------------------------------------------- + +export interface JsmSubmitFormParams extends JsmBaseParams { + issueIdOrKey: string + formId: string +} + +export interface JsmSubmitFormResponse extends ToolResponse { + output: { + ts: string + issueIdOrKey: string + formId: string + status: string + } +} + +// --------------------------------------------------------------------------- +// Get Form (single full form) +// --------------------------------------------------------------------------- + +export interface JsmGetFormParams extends JsmBaseParams { + issueIdOrKey: string + formId: string +} + +export interface JsmGetFormResponse extends ToolResponse { + output: { + ts: string + issueIdOrKey: string + formId: string + design: Record | null + state: { + answers: Record + status: string + visibility: string + } | null + updated: string | null + } +} + +// --------------------------------------------------------------------------- +// Delete Form +// --------------------------------------------------------------------------- + +export interface JsmDeleteFormParams extends JsmBaseParams { + issueIdOrKey: string + formId: string +} + +export interface JsmDeleteFormResponse extends ToolResponse { + output: { + ts: string + issueIdOrKey: string + formId: string + deleted: boolean + } +} + +// --------------------------------------------------------------------------- +// Externalise Form +// --------------------------------------------------------------------------- + +export interface JsmExternaliseFormParams extends JsmBaseParams { + issueIdOrKey: string + formId: string +} + +export interface JsmExternaliseFormResponse extends ToolResponse { + output: { + ts: string + issueIdOrKey: string + formId: string + visibility: string + } +} + +// --------------------------------------------------------------------------- +// Internalise Form +// --------------------------------------------------------------------------- + +export interface JsmInternaliseFormParams extends JsmBaseParams { + issueIdOrKey: string + formId: string +} + +export interface JsmInternaliseFormResponse extends ToolResponse { + output: { + ts: string + issueIdOrKey: string + formId: string + visibility: string + } +} + +// --------------------------------------------------------------------------- +// Copy Forms +// --------------------------------------------------------------------------- + +export interface JsmCopyFormsParams extends JsmBaseParams { + sourceIssueIdOrKey: string + targetIssueIdOrKey: string + formIds?: string[] +} + +export interface JsmCopyFormsResponse extends ToolResponse { + output: { + ts: string + sourceIssueIdOrKey: string + targetIssueIdOrKey: string + copiedForms: Array> + errors: Array> + } +} + +// --------------------------------------------------------------------------- +// Get Form Answers +// --------------------------------------------------------------------------- + +export interface JsmGetFormAnswersParams extends JsmBaseParams { + issueIdOrKey: string + formId: string +} + +export interface JsmGetFormAnswersResponse extends ToolResponse { + output: { + ts: string + issueIdOrKey: string + formId: string + answers: Record | null + } +} + +// --------------------------------------------------------------------------- +// Reopen Form +// --------------------------------------------------------------------------- + +export interface JsmReopenFormParams extends JsmBaseParams { + issueIdOrKey: string + formId: string +} + +export interface JsmReopenFormResponse extends ToolResponse { + output: { + ts: string + issueIdOrKey: string + formId: string + status: string + } +} + // --------------------------------------------------------------------------- // Union type for all JSM responses // --------------------------------------------------------------------------- @@ -929,3 +1124,13 @@ export type JsmResponse = | JsmGetFormTemplatesResponse | JsmGetFormStructureResponse | JsmGetIssueFormsResponse + | JsmAttachFormResponse + | JsmSaveFormAnswersResponse + | JsmSubmitFormResponse + | JsmGetFormResponse + | JsmDeleteFormResponse + | JsmExternaliseFormResponse + | JsmInternaliseFormResponse + | JsmCopyFormsResponse + | JsmGetFormAnswersResponse + | JsmReopenFormResponse diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index a90264d546..c2177e1505 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1326,13 +1326,19 @@ import { jsmAddOrganizationTool, jsmAddParticipantsTool, jsmAnswerApprovalTool, + jsmAttachFormTool, + jsmCopyFormsTool, jsmCreateOrganizationTool, jsmCreateRequestTool, + jsmDeleteFormTool, + jsmExternaliseFormTool, jsmGetApprovalsTool, jsmGetCommentsTool, jsmGetCustomersTool, + jsmGetFormAnswersTool, jsmGetFormStructureTool, jsmGetFormTemplatesTool, + jsmGetFormTool, jsmGetIssueFormsTool, jsmGetOrganizationsTool, jsmGetParticipantsTool, @@ -1344,6 +1350,10 @@ import { jsmGetServiceDesksTool, jsmGetSlaTool, jsmGetTransitionsTool, + jsmInternaliseFormTool, + jsmReopenFormTool, + jsmSaveFormAnswersTool, + jsmSubmitFormTool, jsmTransitionRequestTool, } from '@/tools/jsm' import { @@ -3153,9 +3163,19 @@ export const tools: Record = { jsm_add_participants: jsmAddParticipantsTool, jsm_get_approvals: jsmGetApprovalsTool, jsm_answer_approval: jsmAnswerApprovalTool, + jsm_get_form: jsmGetFormTool, + jsm_get_form_answers: jsmGetFormAnswersTool, jsm_get_form_templates: jsmGetFormTemplatesTool, jsm_get_form_structure: jsmGetFormStructureTool, jsm_get_issue_forms: jsmGetIssueFormsTool, + jsm_attach_form: jsmAttachFormTool, + jsm_save_form_answers: jsmSaveFormAnswersTool, + jsm_submit_form: jsmSubmitFormTool, + jsm_reopen_form: jsmReopenFormTool, + jsm_delete_form: jsmDeleteFormTool, + jsm_externalise_form: jsmExternaliseFormTool, + jsm_internalise_form: jsmInternaliseFormTool, + jsm_copy_forms: jsmCopyFormsTool, kalshi_get_markets: kalshiGetMarketsTool, kalshi_get_markets_v2: kalshiGetMarketsV2Tool, kalshi_get_market: kalshiGetMarketTool, From 47f5ed8c1caaede46f5f019bef5c2d31cb34bc5f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Apr 2026 18:13:36 -0700 Subject: [PATCH 2/5] removed tyoes --- apps/sim/tools/confluence/types.ts | 6 --- apps/sim/tools/jsm/types.ts | 60 ------------------------------ 2 files changed, 66 deletions(-) diff --git a/apps/sim/tools/confluence/types.ts b/apps/sim/tools/confluence/types.ts index a786e99242..057fc0d350 100644 --- a/apps/sim/tools/confluence/types.ts +++ b/apps/sim/tools/confluence/types.ts @@ -495,7 +495,6 @@ export const URL_OUTPUT: OutputProperty = { description: 'URL to view in Confluence', } -// Page operations export interface ConfluenceRetrieveParams { accessToken: string pageId: string @@ -573,7 +572,6 @@ export interface ConfluenceDeletePageResponse extends ToolResponse { } } -// Search operations export interface ConfluenceSearchParams { accessToken: string domain: string @@ -595,7 +593,6 @@ export interface ConfluenceSearchResponse extends ToolResponse { } } -// Comment operations export interface ConfluenceCommentParams { accessToken: string domain: string @@ -612,7 +609,6 @@ export interface ConfluenceCommentResponse extends ToolResponse { } } -// Attachment operations export interface ConfluenceAttachmentParams { accessToken: string domain: string @@ -659,7 +655,6 @@ export interface ConfluenceUploadAttachmentResponse extends ToolResponse { } } -// Label operations export interface ConfluenceLabelParams { accessToken: string domain: string @@ -683,7 +678,6 @@ export interface ConfluenceLabelResponse extends ToolResponse { } } -// Space operations export interface ConfluenceSpaceParams { accessToken: string domain: string diff --git a/apps/sim/tools/jsm/types.ts b/apps/sim/tools/jsm/types.ts index 6acf999948..b0bd63a1af 100644 --- a/apps/sim/tools/jsm/types.ts +++ b/apps/sim/tools/jsm/types.ts @@ -1,9 +1,5 @@ import type { ToolResponse } from '@/tools/types' -// --------------------------------------------------------------------------- -// Shared output property constants for JSM tools (following Confluence pattern) -// --------------------------------------------------------------------------- - /** Reusable date output properties with ISO 8601, friendly, and epoch formats */ export const DATE_OUTPUT_PROPERTIES = { iso8601: { type: 'string', description: 'ISO 8601 formatted date' }, @@ -260,10 +256,6 @@ export const ISSUE_FORM_PROPERTIES = { }, } as const -// --------------------------------------------------------------------------- -// Data model interfaces -// --------------------------------------------------------------------------- - /** Common parameters for all JSM API calls */ export interface JsmBaseParams { accessToken: string @@ -427,10 +419,6 @@ export interface JsmRequestTypeField { jiraSchema: { type: string; system?: string; custom?: string; customId?: number } } -// --------------------------------------------------------------------------- -// Params interfaces -// --------------------------------------------------------------------------- - export interface JsmGetServiceDesksParams extends JsmBaseParams { expand?: string start?: number @@ -570,10 +558,6 @@ export interface JsmGetRequestTypeFieldsParams extends JsmBaseParams { requestTypeId: string } -// --------------------------------------------------------------------------- -// Response interfaces -// --------------------------------------------------------------------------- - export interface JsmGetServiceDesksResponse extends ToolResponse { output: { ts: string @@ -899,10 +883,6 @@ export interface JsmGetIssueFormsResponse extends ToolResponse { } } -// --------------------------------------------------------------------------- -// Attach Form -// --------------------------------------------------------------------------- - export interface JsmAttachFormParams extends JsmBaseParams { issueIdOrKey: string formTemplateId: string @@ -922,10 +902,6 @@ export interface JsmAttachFormResponse extends ToolResponse { } } -// --------------------------------------------------------------------------- -// Save Form Answers -// --------------------------------------------------------------------------- - export interface JsmSaveFormAnswersParams extends JsmBaseParams { issueIdOrKey: string formId: string @@ -942,10 +918,6 @@ export interface JsmSaveFormAnswersResponse extends ToolResponse { } } -// --------------------------------------------------------------------------- -// Submit Form -// --------------------------------------------------------------------------- - export interface JsmSubmitFormParams extends JsmBaseParams { issueIdOrKey: string formId: string @@ -960,10 +932,6 @@ export interface JsmSubmitFormResponse extends ToolResponse { } } -// --------------------------------------------------------------------------- -// Get Form (single full form) -// --------------------------------------------------------------------------- - export interface JsmGetFormParams extends JsmBaseParams { issueIdOrKey: string formId: string @@ -984,10 +952,6 @@ export interface JsmGetFormResponse extends ToolResponse { } } -// --------------------------------------------------------------------------- -// Delete Form -// --------------------------------------------------------------------------- - export interface JsmDeleteFormParams extends JsmBaseParams { issueIdOrKey: string formId: string @@ -1002,10 +966,6 @@ export interface JsmDeleteFormResponse extends ToolResponse { } } -// --------------------------------------------------------------------------- -// Externalise Form -// --------------------------------------------------------------------------- - export interface JsmExternaliseFormParams extends JsmBaseParams { issueIdOrKey: string formId: string @@ -1020,10 +980,6 @@ export interface JsmExternaliseFormResponse extends ToolResponse { } } -// --------------------------------------------------------------------------- -// Internalise Form -// --------------------------------------------------------------------------- - export interface JsmInternaliseFormParams extends JsmBaseParams { issueIdOrKey: string formId: string @@ -1038,10 +994,6 @@ export interface JsmInternaliseFormResponse extends ToolResponse { } } -// --------------------------------------------------------------------------- -// Copy Forms -// --------------------------------------------------------------------------- - export interface JsmCopyFormsParams extends JsmBaseParams { sourceIssueIdOrKey: string targetIssueIdOrKey: string @@ -1058,10 +1010,6 @@ export interface JsmCopyFormsResponse extends ToolResponse { } } -// --------------------------------------------------------------------------- -// Get Form Answers -// --------------------------------------------------------------------------- - export interface JsmGetFormAnswersParams extends JsmBaseParams { issueIdOrKey: string formId: string @@ -1076,10 +1024,6 @@ export interface JsmGetFormAnswersResponse extends ToolResponse { } } -// --------------------------------------------------------------------------- -// Reopen Form -// --------------------------------------------------------------------------- - export interface JsmReopenFormParams extends JsmBaseParams { issueIdOrKey: string formId: string @@ -1094,10 +1038,6 @@ export interface JsmReopenFormResponse extends ToolResponse { } } -// --------------------------------------------------------------------------- -// Union type for all JSM responses -// --------------------------------------------------------------------------- - /** Union type for all JSM responses */ export type JsmResponse = | JsmGetServiceDesksResponse From 0289927ecd9c33a36fac41bfca90207ac37764b7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Apr 2026 18:16:17 -0700 Subject: [PATCH 3/5] fix(jsm): handle 204 No Content on action endpoints and reject array answers --- apps/sim/app/api/tools/jsm/forms/externalise/route.ts | 3 ++- apps/sim/app/api/tools/jsm/forms/internalise/route.ts | 3 ++- apps/sim/app/api/tools/jsm/forms/reopen/route.ts | 3 ++- apps/sim/app/api/tools/jsm/forms/save/route.ts | 2 +- apps/sim/app/api/tools/jsm/forms/submit/route.ts | 3 ++- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/tools/jsm/forms/externalise/route.ts b/apps/sim/app/api/tools/jsm/forms/externalise/route.ts index 6d0e8b57a6..71edbcc0f5 100644 --- a/apps/sim/app/api/tools/jsm/forms/externalise/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/externalise/route.ts @@ -83,7 +83,8 @@ export async function POST(request: NextRequest) { ) } - const data = await response.json() + const bodyText = await response.text() + const data = bodyText ? JSON.parse(bodyText) : {} return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/tools/jsm/forms/internalise/route.ts b/apps/sim/app/api/tools/jsm/forms/internalise/route.ts index 8d821ced59..9524207376 100644 --- a/apps/sim/app/api/tools/jsm/forms/internalise/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/internalise/route.ts @@ -83,7 +83,8 @@ export async function POST(request: NextRequest) { ) } - const data = await response.json() + const bodyText = await response.text() + const data = bodyText ? JSON.parse(bodyText) : {} return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/tools/jsm/forms/reopen/route.ts b/apps/sim/app/api/tools/jsm/forms/reopen/route.ts index df9a452a02..afadc12484 100644 --- a/apps/sim/app/api/tools/jsm/forms/reopen/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/reopen/route.ts @@ -83,7 +83,8 @@ export async function POST(request: NextRequest) { ) } - const data = await response.json() + const bodyText = await response.text() + const data = bodyText ? JSON.parse(bodyText) : {} return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/tools/jsm/forms/save/route.ts b/apps/sim/app/api/tools/jsm/forms/save/route.ts index 6e4f8724b0..97f8c91c58 100644 --- a/apps/sim/app/api/tools/jsm/forms/save/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/save/route.ts @@ -39,7 +39,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Form ID is required' }, { status: 400 }) } - if (!answers || typeof answers !== 'object') { + if (!answers || typeof answers !== 'object' || Array.isArray(answers)) { logger.error('Missing or invalid answers in request') return NextResponse.json({ error: 'Answers object is required' }, { status: 400 }) } diff --git a/apps/sim/app/api/tools/jsm/forms/submit/route.ts b/apps/sim/app/api/tools/jsm/forms/submit/route.ts index 048d06363c..38d50189d2 100644 --- a/apps/sim/app/api/tools/jsm/forms/submit/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/submit/route.ts @@ -83,7 +83,8 @@ export async function POST(request: NextRequest) { ) } - const data = await response.json() + const bodyText = await response.text() + const data = bodyText ? JSON.parse(bodyText) : {} return NextResponse.json({ success: true, From 37152fd34978baa8962c9e2b9938bb09d3759516 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Apr 2026 18:36:57 -0700 Subject: [PATCH 4/5] fix(jsm): validate formIds is an array in copy_forms route and block --- apps/sim/app/api/tools/jsm/forms/copy/route.ts | 4 ++++ apps/sim/blocks/blocks/jira_service_management.ts | 12 +++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/tools/jsm/forms/copy/route.ts b/apps/sim/app/api/tools/jsm/forms/copy/route.ts index bda48e394a..7872735c05 100644 --- a/apps/sim/app/api/tools/jsm/forms/copy/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/copy/route.ts @@ -66,6 +66,10 @@ export async function POST(request: NextRequest) { const baseUrl = getJsmFormsApiBaseUrl(cloudId) const url = `${baseUrl}/issue/${encodeURIComponent(sourceIssueIdOrKey)}/form/copy/${encodeURIComponent(targetIssueIdOrKey)}` + if (formIds !== undefined && !Array.isArray(formIds)) { + return NextResponse.json({ error: 'formIds must be an array of form UUIDs' }, { status: 400 }) + } + const requestBody = Array.isArray(formIds) && formIds.length > 0 ? { ids: formIds } : {} logger.info('Copying forms:', { url, sourceIssueIdOrKey, targetIssueIdOrKey, formIds }) diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index 3416192814..0a1cdcc51a 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -1092,9 +1092,15 @@ Return ONLY the comment text - no explanations.`, formIds: params.formIds ? (() => { try { - return JSON.parse(params.formIds) - } catch { - throw new Error('formIds must be valid JSON array') + const parsed = JSON.parse(params.formIds) + if (!Array.isArray(parsed)) { + throw new Error('formIds must be a JSON array') + } + return parsed + } catch (e) { + throw e instanceof Error && e.message === 'formIds must be a JSON array' + ? e + : new Error('formIds must be valid JSON array') } })() : undefined, From 3bf93379afc52f090a0e60d34e5e78f7dca023a8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Apr 2026 18:49:07 -0700 Subject: [PATCH 5/5] fix(jsm): add formTemplateId validation and conditional required on formAnswers --- apps/sim/app/api/tools/jsm/forms/attach/route.ts | 5 +++++ apps/sim/blocks/blocks/jira_service_management.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/tools/jsm/forms/attach/route.ts b/apps/sim/app/api/tools/jsm/forms/attach/route.ts index a2f5806333..dee8b1549c 100644 --- a/apps/sim/app/api/tools/jsm/forms/attach/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/attach/route.ts @@ -51,6 +51,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 }) } + const formTemplateIdValidation = validateJiraCloudId(formTemplateId, 'formTemplateId') + if (!formTemplateIdValidation.isValid) { + return NextResponse.json({ error: formTemplateIdValidation.error }, { status: 400 }) + } + const baseUrl = getJsmFormsApiBaseUrl(cloudId) const url = `${baseUrl}/issue/${encodeURIComponent(issueIdOrKey)}/form` diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index 0a1cdcc51a..9c75d4c0e4 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -356,7 +356,7 @@ Return ONLY the description text - no explanations.`, type: 'long-input', placeholder: 'JSON object using form question IDs as keys (e.g., {"1": {"text": "Title"}, "4": {"choices": ["5"]}, "14": {"text": "Details"}})', - mode: 'advanced', + required: { field: 'operation', value: 'save_form_answers' }, condition: { field: 'operation', value: ['create_request', 'save_form_answers'] }, }, {