diff --git a/apps/docs/content/docs/integrations/windchill.mdx b/apps/docs/content/docs/integrations/windchill.mdx index 7f2b4dbfab1..bfe4859dfe8 100644 --- a/apps/docs/content/docs/integrations/windchill.mdx +++ b/apps/docs/content/docs/integrations/windchill.mdx @@ -23,6 +23,10 @@ This integration talks to Windchill REST Services (WRS) 2.7 over OData — the q Bulk actions are atomic on Windchill's side: PTC documents that if the action fails for any object in the collection, the entire action is rolled back and nothing changes. Two limits are worth knowing before you build. Windchill identifies everything by OID (`OR:wt.doc.WTDocument:48796581`), so most operations need an OID you got from a list or get call rather than a document number. And this integration supports Basic authentication only — Windchill deployments fronted by OAuth are not currently supported. + +The block also supports provider-managed document webhook triggers for attribute changes, identity changes, lifecycle-state changes, and custom installed events. Choose one document, a folder, or a container as the subscription scope. Triggers require a self-managed Windchill deployment with WRS 2.7 and a Basic-authenticated account that can manage Event Management subscriptions; Windchill+ and OAuth authentication are not supported. A Windchill administrator must set the site-level **Webhooks > Outgoing Webhook URL** to the same host as the generated Sim callback URL. + +Sim creates the subscription when the trigger is saved and removes it during cleanup. Keep the generated callback URL private: PTC does not publish a callback payload schema, signing contract, or delivery identifier. Sim passes the JSON callback body through unchanged rather than exposing inferred event fields. {/* MANUAL-CONTENT-END */} @@ -935,3 +939,87 @@ Upload one or more files as document attachments | `uploadedFileNames` | array | Names of files accepted by Windchill | + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### Windchill Custom Document Event + +Trigger a workflow for an installed Windchill document event + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerBaseUrl` | string | Yes | Complete versioned HTTPS OData service root for a self-managed Windchill deployment; Sim uses its installation root to access the default EventMgmt domain | +| `triggerUsername` | string | Yes | Username | +| `triggerPassword` | string | Yes | Password | +| `triggerScope` | string | Yes | Scope | +| `triggerDocumentOid` | string | No | Subscribe to one WT.Document object | +| `triggerSubscribeAllVersions` | boolean | No | Apply the subscription to current and future versions of the document | +| `triggerFolderOid` | string | No | Subscribe to documents in this folder | +| `triggerContainerOid` | string | No | Subscribe to documents in this product, library, or other container | +| `triggerEvent` | string | Yes | Exact event identifier returned by EventMgmt/GetApplicableEvents\(EntityName='PTC.DocMgmt.Document'\) | + + +--- + +### Windchill Document Attributes Changed + +Trigger a workflow when Windchill document attributes change + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerBaseUrl` | string | Yes | Complete versioned HTTPS OData service root for a self-managed Windchill deployment; Sim uses its installation root to access the default EventMgmt domain | +| `triggerUsername` | string | Yes | Username | +| `triggerPassword` | string | Yes | Password | +| `triggerScope` | string | Yes | Scope | +| `triggerDocumentOid` | string | No | Subscribe to one WT.Document object | +| `triggerSubscribeAllVersions` | boolean | No | Apply the subscription to current and future versions of the document | +| `triggerFolderOid` | string | No | Subscribe to documents in this folder | +| `triggerContainerOid` | string | No | Subscribe to documents in this product, library, or other container | + + +--- + +### Windchill Document Identity Changed + +Trigger a workflow when a Windchill document identity changes + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerBaseUrl` | string | Yes | Complete versioned HTTPS OData service root for a self-managed Windchill deployment; Sim uses its installation root to access the default EventMgmt domain | +| `triggerUsername` | string | Yes | Username | +| `triggerPassword` | string | Yes | Password | +| `triggerScope` | string | Yes | Scope | +| `triggerDocumentOid` | string | No | Subscribe to one WT.Document object | +| `triggerSubscribeAllVersions` | boolean | No | Apply the subscription to current and future versions of the document | +| `triggerFolderOid` | string | No | Subscribe to documents in this folder | +| `triggerContainerOid` | string | No | Subscribe to documents in this product, library, or other container | + + +--- + +### Windchill Document Lifecycle State Changed + +Trigger a workflow when a Windchill document enters a selected lifecycle state + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerBaseUrl` | string | Yes | Complete versioned HTTPS OData service root for a self-managed Windchill deployment; Sim uses its installation root to access the default EventMgmt domain | +| `triggerUsername` | string | Yes | Username | +| `triggerPassword` | string | Yes | Password | +| `triggerScope` | string | Yes | Scope | +| `triggerDocumentOid` | string | No | Subscribe to one WT.Document object | +| `triggerSubscribeAllVersions` | boolean | No | Apply the subscription to current and future versions of the document | +| `triggerFolderOid` | string | No | Subscribe to documents in this folder | +| `triggerContainerOid` | string | No | Subscribe to documents in this product, library, or other container | +| `triggerLifecycleStateValue` | string | Yes | Internal lifecycle state value that should trigger the workflow | + diff --git a/apps/sim/blocks/blocks/windchill.ts b/apps/sim/blocks/blocks/windchill.ts index b3b8fef2282..a5ffd532d60 100644 --- a/apps/sim/blocks/blocks/windchill.ts +++ b/apps/sim/blocks/blocks/windchill.ts @@ -3,6 +3,7 @@ import { WindchillIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' +import { getTrigger } from '@/triggers' const SINGLE_DOCUMENT_OPERATIONS = [ 'windchill_get_document', @@ -130,11 +131,42 @@ export const WindchillBlock: BlockConfig = { category: 'tools', integrationType: IntegrationType.Documents, authMode: AuthMode.ApiKey, + triggerAllowed: true, bgColor: '#FFFFFF', icon: WindchillIcon, canvasPresentation: { defaultTitle: 'Windchill', operationSubBlockId: 'operation', + triggerSentences: { + default: [ + 'Run on', + { field: 'selectedTriggerId', core: true }, + { + text: 'for', + field: ['triggerDocumentOid', 'triggerFolderOid', 'triggerContainerOid'], + core: true, + }, + ], + byTrigger: { + windchill_document_lifecycle_state_changed: [ + 'Run when', + { + field: ['triggerDocumentOid', 'triggerFolderOid', 'triggerContainerOid'], + core: true, + }, + { text: 'enters', field: 'triggerLifecycleStateValue', core: true }, + ], + windchill_custom_document_event: [ + 'Run on', + { field: 'triggerEvent', core: true }, + { + text: 'for', + field: ['triggerDocumentOid', 'triggerFolderOid', 'triggerContainerOid'], + core: true, + }, + ], + }, + }, sentences: { byOperation: { windchill_list_documents: [ @@ -721,6 +753,10 @@ export const WindchillBlock: BlockConfig = { mode: 'advanced', required: true, }, + ...getTrigger('windchill_document_attributes_changed').subBlocks, + ...getTrigger('windchill_document_identity_changed').subBlocks, + ...getTrigger('windchill_document_lifecycle_state_changed').subBlocks, + ...getTrigger('windchill_custom_document_event').subBlocks, ], tools: { access: [ @@ -920,6 +956,15 @@ export const WindchillBlock: BlockConfig = { condition: { field: 'operation', value: DOWNLOAD_OPERATIONS }, }, }, + triggers: { + enabled: true, + available: [ + 'windchill_document_attributes_changed', + 'windchill_document_identity_changed', + 'windchill_document_lifecycle_state_changed', + 'windchill_custom_document_event', + ], + }, } export const WindchillBlockMeta = { diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 9a690cb9e27..fc5e5711469 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -63,6 +63,7 @@ import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' import { vercelHandler } from '@/lib/webhooks/providers/vercel' import { webflowHandler } from '@/lib/webhooks/providers/webflow' import { whatsappHandler } from '@/lib/webhooks/providers/whatsapp' +import { windchillHandler } from '@/lib/webhooks/providers/windchill' import { zendeskHandler } from '@/lib/webhooks/providers/zendesk' import { zohoDeskHandler } from '@/lib/webhooks/providers/zoho-desk' import { zoomHandler } from '@/lib/webhooks/providers/zoom' @@ -134,6 +135,7 @@ const PROVIDER_HANDLERS: Record = { vercel: vercelHandler, webflow: webflowHandler, whatsapp: whatsappHandler, + windchill: windchillHandler, zendesk: zendeskHandler, zoho_desk: zohoDeskHandler, zoom: zoomHandler, diff --git a/apps/sim/lib/webhooks/providers/windchill.test.ts b/apps/sim/lib/webhooks/providers/windchill.test.ts new file mode 100644 index 00000000000..f0c34179609 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/windchill.test.ts @@ -0,0 +1,313 @@ +/** + * @vitest-environment node + */ +import { resetEnvMock, setEnv } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { MockWindchillProviderError, mockCreateWindchillSession, mockWindchillMutationRequest } = + vi.hoisted(() => { + class MockWindchillProviderError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + this.name = 'WindchillProviderError' + } + } + + return { + MockWindchillProviderError, + mockCreateWindchillSession: vi.fn(), + mockWindchillMutationRequest: vi.fn(), + } + }) + +vi.mock('@/lib/internal/windchill/client', () => ({ + createWindchillSession: mockCreateWindchillSession, + WindchillProviderError: MockWindchillProviderError, + windchillMutationRequest: mockWindchillMutationRequest, +})) + +import type { DeleteSubscriptionContext, SubscriptionContext } from '@/lib/webhooks/providers/types' +import { windchillHandler } from '@/lib/webhooks/providers/windchill' +import { WindchillBlock } from '@/blocks/blocks/windchill' +import { getTrigger } from '@/triggers' +import { + windchillCustomDocumentEventTrigger, + windchillDocumentAttributesChangedTrigger, + windchillDocumentIdentityChangedTrigger, + windchillDocumentLifecycleStateChangedTrigger, +} from '@/triggers/windchill' + +const BASE_CONFIG = { + triggerBaseUrl: 'https://windchill.example.com/Windchill/servlet/odata/v6', + triggerUsername: 'windchill-user', + triggerPassword: 'windchill-password', + triggerId: 'windchill_document_attributes_changed', + triggerScope: 'document', + triggerDocumentOid: 'OR:wt.doc.WTDocument:1', + triggerSubscribeAllVersions: true, +} + +function subscriptionContext(providerConfig: Record) { + return { + webhook: { id: 'webhook-1', path: 'windchill-path', providerConfig }, + workflow: {}, + userId: 'user-1', + requestId: 'request-1', + request: new NextRequest('http://localhost/test'), + } +} + +function deleteContext(providerConfig: Record, strict = false) { + return { + webhook: { id: 'webhook-1', providerConfig }, + workflow: {}, + requestId: 'request-delete-1', + strict, + } +} + +async function createSubscription(ctx: SubscriptionContext) { + if (!windchillHandler.createSubscription) { + throw new Error('Windchill provider must support subscription creation') + } + return windchillHandler.createSubscription(ctx) +} + +async function deleteSubscription(ctx: DeleteSubscriptionContext) { + if (!windchillHandler.deleteSubscription) { + throw new Error('Windchill provider must support subscription cleanup') + } + return windchillHandler.deleteSubscription(ctx) +} + +describe('Windchill webhook provider', () => { + beforeEach(() => { + setEnv({ NEXT_PUBLIC_APP_URL: 'https://app.test' }) + mockCreateWindchillSession.mockReset() + mockWindchillMutationRequest.mockReset() + mockCreateWindchillSession.mockResolvedValue({ + nonceHeader: 'CSRF_NONCE', + nonceValue: 'nonce', + cookie: null, + }) + mockWindchillMutationRequest.mockResolvedValue({ + ID: 'OR:wt.notify.NotificationSubscription:5012541', + }) + }) + + afterEach(() => { + resetEnvMock() + }) + + it('creates an object subscription for the mapped attributes event', async () => { + const result = await createSubscription(subscriptionContext(BASE_CONFIG)) + + expect(mockCreateWindchillSession).toHaveBeenCalledWith({ + baseUrl: BASE_CONFIG.triggerBaseUrl, + username: BASE_CONFIG.triggerUsername, + password: BASE_CONFIG.triggerPassword, + }) + expect(mockWindchillMutationRequest).toHaveBeenCalledWith({ + params: { + baseUrl: BASE_CONFIG.triggerBaseUrl, + username: BASE_CONFIG.triggerUsername, + password: BASE_CONFIG.triggerPassword, + }, + session: { nonceHeader: 'CSRF_NONCE', nonceValue: 'nonce', cookie: null }, + url: 'https://windchill.example.com/Windchill/servlet/odata/EventMgmt/EventSubscriptions', + method: 'POST', + body: { + Name: 'Sim webhook-1 EDIT_ATTRIBUTES', + CallbackURL: 'https://app.test/api/webhooks/trigger/windchill-path', + 'SubscribedEvent@odata.bind': "Events('EDIT_ATTRIBUTES')", + 'SubscribedOnEntity@odata.bind': "WindchillEntities('OR%3Awt.doc.WTDocument%3A1')", + SubscribeAllVersions: true, + '@odata.type': 'PTC.EventMgmt.EntityEventSubscription', + }, + }) + expect(result?.providerConfigUpdates).toEqual({ + externalId: 'OR:wt.notify.NotificationSubscription:5012541', + }) + }) + + it('creates a folder subscription for a custom installed event', async () => { + await createSubscription( + subscriptionContext({ + ...BASE_CONFIG, + triggerId: 'windchill_custom_document_event', + triggerEvent: 'EDIT_CONTENT', + triggerScope: 'folder', + triggerFolderOid: 'OR:wt.folder.SubFolder:2', + }) + ) + + expect(mockWindchillMutationRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://windchill.example.com/Windchill/servlet/odata/EventMgmt/EventSubscriptions', + body: { + Name: 'Sim webhook-1 EDIT_CONTENT', + CallbackURL: 'https://app.test/api/webhooks/trigger/windchill-path', + 'SubscribedEvent@odata.bind': "Events('EDIT_CONTENT')", + SubscribedOnEntityType: 'PTC.DocMgmt.Document', + 'SubscribedOnFolder@odata.bind': "Folders('OR%3Awt.folder.SubFolder%3A2')", + '@odata.type': 'PTC.EventMgmt.EntityTypeInFolderEventSubscription', + }, + }) + ) + }) + + it('creates a container subscription for the mapped identity event', async () => { + await createSubscription( + subscriptionContext({ + ...BASE_CONFIG, + triggerId: 'windchill_document_identity_changed', + triggerScope: 'container', + triggerContainerOid: 'OR:wt.pdmlink.PDMLinkProduct:3', + }) + ) + + expect(mockWindchillMutationRequest).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + 'SubscribedEvent@odata.bind': "Events('EDIT_IDENTITY')", + SubscribedOnEntityType: 'PTC.DocMgmt.Document', + 'SubscribedOnContext@odata.bind': "Containers('OR%3Awt.pdmlink.PDMLinkProduct%3A3')", + '@odata.type': 'PTC.EventMgmt.EntityTypeInContainerEventSubscription', + }), + }) + ) + }) + + it('includes the documented lifecycle state object', async () => { + await createSubscription( + subscriptionContext({ + ...BASE_CONFIG, + triggerId: 'windchill_document_lifecycle_state_changed', + triggerLifecycleStateValue: 'RELEASED', + }) + ) + + expect(mockWindchillMutationRequest).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + 'SubscribedEvent@odata.bind': "Events('CHANGE_LIFECYCLE_STATE')", + LifeCycleState: { Value: 'RELEASED' }, + }), + }) + ) + }) + + it('rejects unsafe custom event identifiers before making a Windchill request', async () => { + await expect( + createSubscription( + subscriptionContext({ + ...BASE_CONFIG, + triggerId: 'windchill_custom_document_event', + triggerEvent: "EDIT_CONTENT')/Anything(", + }) + ) + ).rejects.toThrow('unsupported characters') + expect(mockCreateWindchillSession).not.toHaveBeenCalled() + }) + + it('requires trigger credentials and scope identifiers', async () => { + await expect( + createSubscription(subscriptionContext({ ...BASE_CONFIG, triggerPassword: '' })) + ).rejects.toThrow('Windchill password is required') + + await expect( + createSubscription(subscriptionContext({ ...BASE_CONFIG, triggerDocumentOid: '' })) + ).rejects.toThrow('Windchill document OID is required') + }) + + it('fails deployment when Windchill returns no subscription ID', async () => { + mockWindchillMutationRequest.mockResolvedValue({ Name: 'Created without an ID' }) + + await expect(createSubscription(subscriptionContext(BASE_CONFIG))).rejects.toThrow( + 'no subscription ID was returned' + ) + }) + + it('deletes the external subscription by its Windchill OID', async () => { + await deleteSubscription( + deleteContext({ + ...BASE_CONFIG, + externalId: 'OR:wt.notify.NotificationSubscription:5012541', + }) + ) + + expect(mockWindchillMutationRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://windchill.example.com/Windchill/servlet/odata/EventMgmt/EventSubscriptions('OR%3Awt.notify.NotificationSubscription%3A5012541')", + method: 'DELETE', + }) + ) + }) + + it('treats a missing external subscription as already deleted', async () => { + mockWindchillMutationRequest.mockRejectedValue(new MockWindchillProviderError('Not found', 404)) + + await expect( + deleteSubscription( + deleteContext({ + ...BASE_CONFIG, + externalId: 'OR:wt.notify.NotificationSubscription:5012541', + }) + ) + ).resolves.toBeUndefined() + }) + + it('keeps cleanup non-fatal unless strict cleanup is requested', async () => { + const error = new MockWindchillProviderError('Windchill unavailable', 503) + mockWindchillMutationRequest.mockRejectedValue(error) + const config = { + ...BASE_CONFIG, + externalId: 'OR:wt.notify.NotificationSubscription:5012541', + } + + await expect(deleteSubscription(deleteContext(config))).resolves.toBeUndefined() + await expect(deleteSubscription(deleteContext(config, true))).rejects.toBe(error) + }) +}) + +describe('Windchill trigger configuration', () => { + const triggers = [ + windchillDocumentAttributesChangedTrigger, + windchillDocumentIdentityChangedTrigger, + windchillDocumentLifecycleStateChangedTrigger, + windchillCustomDocumentEventTrigger, + ] + + it('uses the shared dropdown only on the primary trigger', () => { + expect( + windchillDocumentAttributesChangedTrigger.subBlocks.some( + (subBlock) => subBlock.id === 'selectedTriggerId' + ) + ).toBe(true) + for (const trigger of triggers.slice(1)) { + expect(trigger.subBlocks.some((subBlock) => subBlock.id === 'selectedTriggerId')).toBe(false) + } + }) + + it('keeps callback payloads raw until PTC documents their schema', () => { + for (const trigger of triggers) { + expect(trigger.outputs).toEqual({}) + } + expect(windchillHandler.formatInput).toBeUndefined() + expect(windchillHandler.matchEvent).toBeUndefined() + expect(windchillHandler.extractIdempotencyId).toBeUndefined() + }) + + it('registers every trigger on the Windchill block', () => { + const ids = triggers.map((trigger) => trigger.id) + expect(WindchillBlock.triggerAllowed).toBe(true) + expect(WindchillBlock.triggers).toEqual({ enabled: true, available: ids }) + for (const id of ids) { + expect(getTrigger(id).id).toBe(id) + } + }) +}) diff --git a/apps/sim/lib/webhooks/providers/windchill.ts b/apps/sim/lib/webhooks/providers/windchill.ts new file mode 100644 index 00000000000..e26921614da --- /dev/null +++ b/apps/sim/lib/webhooks/providers/windchill.ts @@ -0,0 +1,154 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { + createWindchillSession, + WindchillProviderError, + windchillMutationRequest, +} from '@/lib/internal/windchill/client' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' +import type { + DeleteSubscriptionContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { encodeWindchillOid, normalizeServiceRoot } from '@/tools/windchill/utils' + +const logger = createLogger('WebhookProvider:Windchill') + +function eventManagementRoot(baseUrl: string): string { + return `${normalizeServiceRoot(baseUrl).replace(/\/v\d+$/i, '')}/EventMgmt` +} + +function requiredString(config: Record, key: string, label: string): string { + const value = config[key] + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${label} is required`) + } + return value.trim() +} + +export const windchillHandler: WebhookProviderHandler = { + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const baseUrl = requiredString(config, 'triggerBaseUrl', 'Windchill service root') + const username = requiredString(config, 'triggerUsername', 'Windchill username') + const password = requiredString(config, 'triggerPassword', 'Windchill password') + const triggerId = requiredString(config, 'triggerId', 'Windchill trigger ID') + const scope = requiredString(config, 'triggerScope', 'Windchill subscription scope') + if (scope !== 'document' && scope !== 'folder' && scope !== 'container') { + throw new Error('Windchill subscription scope must be document, folder, or container') + } + + const { resolveWindchillEventId } = await import('@/triggers/windchill/utils') + const eventId = resolveWindchillEventId( + triggerId, + typeof config.triggerEvent === 'string' ? config.triggerEvent : undefined + ) + const lifecycleState = + triggerId === 'windchill_document_lifecycle_state_changed' + ? requiredString(config, 'triggerLifecycleStateValue', 'Windchill lifecycle state value') + : undefined + if (lifecycleState && lifecycleState.length > 256) { + throw new Error('Windchill lifecycle state value must be 256 characters or fewer') + } + + const eventRoot = eventManagementRoot(baseUrl) + const webhookId = + typeof ctx.webhook.id === 'string' && ctx.webhook.id ? ctx.webhook.id : 'webhook' + const requestBody: Record = { + Name: `Sim ${webhookId} ${eventId}`.slice(0, 200), + CallbackURL: getNotificationUrl(ctx.webhook), + 'SubscribedEvent@odata.bind': `Events('${eventId}')`, + } + const subscriptionUrl = `${eventRoot}/EventSubscriptions` + + if (scope === 'document') { + const documentOid = requiredString(config, 'triggerDocumentOid', 'Windchill document OID') + requestBody['SubscribedOnEntity@odata.bind'] = + `WindchillEntities('${encodeWindchillOid(documentOid)}')` + requestBody.SubscribeAllVersions = + config.triggerSubscribeAllVersions !== false && + config.triggerSubscribeAllVersions !== 'false' + requestBody['@odata.type'] = 'PTC.EventMgmt.EntityEventSubscription' + } else if (scope === 'folder') { + const folderOid = requiredString(config, 'triggerFolderOid', 'Windchill folder OID') + requestBody.SubscribedOnEntityType = 'PTC.DocMgmt.Document' + requestBody['SubscribedOnFolder@odata.bind'] = `Folders('${encodeWindchillOid(folderOid)}')` + requestBody['@odata.type'] = 'PTC.EventMgmt.EntityTypeInFolderEventSubscription' + } else { + const containerOid = requiredString(config, 'triggerContainerOid', 'Windchill container OID') + requestBody.SubscribedOnEntityType = 'PTC.DocMgmt.Document' + requestBody['SubscribedOnContext@odata.bind'] = + `Containers('${encodeWindchillOid(containerOid)}')` + requestBody['@odata.type'] = 'PTC.EventMgmt.EntityTypeInContainerEventSubscription' + } + + if (lifecycleState) { + requestBody.LifeCycleState = { Value: lifecycleState } + } + + logger.info(`[${ctx.requestId}] Creating Windchill webhook subscription`, { + webhookId, + triggerId, + eventId, + scope, + }) + + const params = { baseUrl, username, password } + const session = await createWindchillSession(params) + const response = await windchillMutationRequest({ + params, + session, + url: subscriptionUrl, + method: 'POST', + body: requestBody, + }) + const externalId = isRecordLike(response) ? response.ID : undefined + if (typeof externalId !== 'string' || !externalId.trim()) { + throw new Error('Windchill subscription was created but no subscription ID was returned') + } + + logger.info( + `[${ctx.requestId}] Created Windchill webhook subscription ${externalId} for webhook ${webhookId}` + ) + return { providerConfigUpdates: { externalId } } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + + try { + const baseUrl = requiredString(config, 'triggerBaseUrl', 'Windchill service root') + const username = requiredString(config, 'triggerUsername', 'Windchill username') + const password = requiredString(config, 'triggerPassword', 'Windchill password') + const externalId = requiredString(config, 'externalId', 'Windchill subscription ID') + const params = { baseUrl, username, password } + const session = await createWindchillSession(params) + + await windchillMutationRequest({ + params, + session, + url: `${eventManagementRoot(baseUrl)}/EventSubscriptions('${encodeWindchillOid(externalId)}')`, + method: 'DELETE', + }) + logger.info( + `[${ctx.requestId}] Deleted Windchill webhook subscription ${externalId} for webhook ${ctx.webhook.id}` + ) + } catch (error) { + if (error instanceof WindchillProviderError && error.status === 404) { + logger.info( + `[${ctx.requestId}] Windchill webhook subscription was already deleted for webhook ${ctx.webhook.id}` + ) + return + } + + logger.warn( + `[${ctx.requestId}] Failed to delete Windchill webhook subscription for webhook ${ctx.webhook.id} (non-fatal)`, + { message: getErrorMessage(error, 'unknown error') } + ) + if (ctx.strict) throw error + } + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index b791c5fb298..57a6496a378 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -530,6 +530,12 @@ import { webflowFormSubmissionTrigger, } from '@/triggers/webflow' import { whatsappWebhookTrigger } from '@/triggers/whatsapp' +import { + windchillCustomDocumentEventTrigger, + windchillDocumentAttributesChangedTrigger, + windchillDocumentIdentityChangedTrigger, + windchillDocumentLifecycleStateChangedTrigger, +} from '@/triggers/windchill' import { zendeskTicketCommentAddedTrigger, zendeskTicketCreatedTrigger, @@ -915,6 +921,10 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { tiktok_post_publish_complete: tiktokPostPublishCompleteTrigger, tiktok_post_publish_failed: tiktokPostPublishFailedTrigger, typeform_webhook: typeformWebhookTrigger, + windchill_document_attributes_changed: windchillDocumentAttributesChangedTrigger, + windchill_document_identity_changed: windchillDocumentIdentityChangedTrigger, + windchill_document_lifecycle_state_changed: windchillDocumentLifecycleStateChangedTrigger, + windchill_custom_document_event: windchillCustomDocumentEventTrigger, whatsapp_webhook: whatsappWebhookTrigger, google_forms_webhook: googleFormsWebhookTrigger, twilio_voice_webhook: twilioVoiceWebhookTrigger, diff --git a/apps/sim/triggers/windchill/custom_document_event.ts b/apps/sim/triggers/windchill/custom_document_event.ts new file mode 100644 index 00000000000..a470df99667 --- /dev/null +++ b/apps/sim/triggers/windchill/custom_document_event.ts @@ -0,0 +1,27 @@ +import { WindchillIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildWindchillExtraFields, + windchillSetupInstructions, + windchillTriggerOptions, +} from '@/triggers/windchill/utils' + +export const windchillCustomDocumentEventTrigger: TriggerConfig = { + id: 'windchill_custom_document_event', + name: 'Windchill Custom Document Event', + provider: 'windchill', + description: 'Trigger a workflow for an installed Windchill document event', + version: '1.0.0', + icon: WindchillIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'windchill_custom_document_event', + triggerOptions: windchillTriggerOptions, + setupInstructions: windchillSetupInstructions('an installed document event'), + extraFields: buildWindchillExtraFields('windchill_custom_document_event', { + customEvent: true, + }), + }), + outputs: {}, + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} diff --git a/apps/sim/triggers/windchill/document_attributes_changed.ts b/apps/sim/triggers/windchill/document_attributes_changed.ts new file mode 100644 index 00000000000..3b03603ebf7 --- /dev/null +++ b/apps/sim/triggers/windchill/document_attributes_changed.ts @@ -0,0 +1,26 @@ +import { WindchillIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildWindchillExtraFields, + windchillSetupInstructions, + windchillTriggerOptions, +} from '@/triggers/windchill/utils' + +export const windchillDocumentAttributesChangedTrigger: TriggerConfig = { + id: 'windchill_document_attributes_changed', + name: 'Windchill Document Attributes Changed', + provider: 'windchill', + description: 'Trigger a workflow when Windchill document attributes change', + version: '1.0.0', + icon: WindchillIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'windchill_document_attributes_changed', + triggerOptions: windchillTriggerOptions, + includeDropdown: true, + setupInstructions: windchillSetupInstructions('document attribute changes'), + extraFields: buildWindchillExtraFields('windchill_document_attributes_changed'), + }), + outputs: {}, + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} diff --git a/apps/sim/triggers/windchill/document_identity_changed.ts b/apps/sim/triggers/windchill/document_identity_changed.ts new file mode 100644 index 00000000000..9f1436484bd --- /dev/null +++ b/apps/sim/triggers/windchill/document_identity_changed.ts @@ -0,0 +1,25 @@ +import { WindchillIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildWindchillExtraFields, + windchillSetupInstructions, + windchillTriggerOptions, +} from '@/triggers/windchill/utils' + +export const windchillDocumentIdentityChangedTrigger: TriggerConfig = { + id: 'windchill_document_identity_changed', + name: 'Windchill Document Identity Changed', + provider: 'windchill', + description: 'Trigger a workflow when a Windchill document identity changes', + version: '1.0.0', + icon: WindchillIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'windchill_document_identity_changed', + triggerOptions: windchillTriggerOptions, + setupInstructions: windchillSetupInstructions('document identity changes'), + extraFields: buildWindchillExtraFields('windchill_document_identity_changed'), + }), + outputs: {}, + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} diff --git a/apps/sim/triggers/windchill/document_lifecycle_state_changed.ts b/apps/sim/triggers/windchill/document_lifecycle_state_changed.ts new file mode 100644 index 00000000000..6ece905e385 --- /dev/null +++ b/apps/sim/triggers/windchill/document_lifecycle_state_changed.ts @@ -0,0 +1,27 @@ +import { WindchillIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildWindchillExtraFields, + windchillSetupInstructions, + windchillTriggerOptions, +} from '@/triggers/windchill/utils' + +export const windchillDocumentLifecycleStateChangedTrigger: TriggerConfig = { + id: 'windchill_document_lifecycle_state_changed', + name: 'Windchill Document Lifecycle State Changed', + provider: 'windchill', + description: 'Trigger a workflow when a Windchill document enters a selected lifecycle state', + version: '1.0.0', + icon: WindchillIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'windchill_document_lifecycle_state_changed', + triggerOptions: windchillTriggerOptions, + setupInstructions: windchillSetupInstructions('a document lifecycle state change'), + extraFields: buildWindchillExtraFields('windchill_document_lifecycle_state_changed', { + lifecycleState: true, + }), + }), + outputs: {}, + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, +} diff --git a/apps/sim/triggers/windchill/index.ts b/apps/sim/triggers/windchill/index.ts new file mode 100644 index 00000000000..f8606093246 --- /dev/null +++ b/apps/sim/triggers/windchill/index.ts @@ -0,0 +1,4 @@ +export { windchillCustomDocumentEventTrigger } from './custom_document_event' +export { windchillDocumentAttributesChangedTrigger } from './document_attributes_changed' +export { windchillDocumentIdentityChangedTrigger } from './document_identity_changed' +export { windchillDocumentLifecycleStateChangedTrigger } from './document_lifecycle_state_changed' diff --git a/apps/sim/triggers/windchill/utils.ts b/apps/sim/triggers/windchill/utils.ts new file mode 100644 index 00000000000..eeced67ed53 --- /dev/null +++ b/apps/sim/triggers/windchill/utils.ts @@ -0,0 +1,191 @@ +import type { SubBlockConfig } from '@/blocks/types' + +export const windchillTriggerOptions = [ + { label: 'Document Attributes Changed', id: 'windchill_document_attributes_changed' }, + { label: 'Document Identity Changed', id: 'windchill_document_identity_changed' }, + { + label: 'Document Lifecycle State Changed', + id: 'windchill_document_lifecycle_state_changed', + }, + { label: 'Custom Document Event', id: 'windchill_custom_document_event' }, +] + +const WINDCHILL_EVENT_BY_TRIGGER: Record = { + windchill_document_attributes_changed: 'EDIT_ATTRIBUTES', + windchill_document_identity_changed: 'EDIT_IDENTITY', + windchill_document_lifecycle_state_changed: 'CHANGE_LIFECYCLE_STATE', +} + +export function resolveWindchillEventId( + triggerId: string | undefined, + customEvent: string | undefined +): string { + const eventId = (triggerId ? WINDCHILL_EVENT_BY_TRIGGER[triggerId] : undefined) ?? customEvent + const trimmed = eventId?.trim() + if (!trimmed) { + throw new Error('A Windchill event identifier is required') + } + if (!/^[A-Za-z0-9_.:-]{1,256}$/.test(trimmed)) { + throw new Error('Windchill event identifier contains unsupported characters') + } + return trimmed +} + +export function windchillSetupInstructions(eventLabel: string): string { + const instructions = [ + 'Requirement: This trigger targets a self-managed Windchill deployment with Windchill REST Services 2.7. Windchill+ does not support webhook subscriptions.', + 'Ask a Windchill administrator to set Webhooks > Outgoing Webhook URL at the site level to the same host as the generated Sim webhook URL. Windchill allows only one configured outgoing webhook host.', + 'Enter the versioned Windchill service root and credentials for a Basic-authenticated account allowed to manage Event Management subscriptions. OAuth authentication is not supported by this trigger.', + `Choose the document scope for ${eventLabel}. When you save, Sim creates the Windchill subscription automatically.`, + 'Keep the generated webhook URL private. PTC does not document a webhook signing mechanism, so Sim accepts deliveries through the unique callback URL and passes the JSON body through unchanged.', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +function triggerCondition(triggerId: string) { + return { field: 'selectedTriggerId', value: triggerId } +} + +function scopedCondition(triggerId: string, scope: 'document' | 'folder' | 'container') { + return { + field: 'selectedTriggerId', + value: triggerId, + and: { field: 'triggerScope', value: scope }, + } +} + +export function buildWindchillExtraFields( + triggerId: string, + options: { lifecycleState?: boolean; customEvent?: boolean } = {} +): SubBlockConfig[] { + const fields: SubBlockConfig[] = [ + { + id: 'triggerBaseUrl', + title: 'Service Root', + type: 'short-input', + placeholder: 'https://host/Windchill/servlet/odata/v6', + description: + 'Complete versioned HTTPS OData service root for a self-managed Windchill deployment; Sim uses its installation root to access the default EventMgmt domain', + required: true, + paramVisibility: 'user-only', + mode: 'trigger', + condition: triggerCondition(triggerId), + }, + { + id: 'triggerUsername', + title: 'Username', + type: 'short-input', + placeholder: 'Windchill username', + required: true, + paramVisibility: 'user-only', + mode: 'trigger', + condition: triggerCondition(triggerId), + }, + { + id: 'triggerPassword', + title: 'Password', + type: 'short-input', + placeholder: 'Windchill password', + password: true, + required: true, + paramVisibility: 'user-only', + mode: 'trigger', + condition: triggerCondition(triggerId), + }, + { + id: 'triggerScope', + title: 'Scope', + type: 'dropdown', + options: [ + { label: 'Document', id: 'document' }, + { label: 'Folder', id: 'folder' }, + { label: 'Container', id: 'container' }, + ], + defaultValue: 'document', + required: true, + paramVisibility: 'user-only', + mode: 'trigger', + condition: triggerCondition(triggerId), + }, + { + id: 'triggerDocumentOid', + title: 'Document OID', + type: 'short-input', + placeholder: 'OR:wt.doc.WTDocument:48796581', + description: 'Subscribe to one WT.Document object', + required: scopedCondition(triggerId, 'document'), + paramVisibility: 'user-only', + mode: 'trigger', + condition: scopedCondition(triggerId, 'document'), + }, + { + id: 'triggerSubscribeAllVersions', + title: 'Subscribe to All Versions', + type: 'switch', + description: 'Apply the subscription to current and future versions of the document', + defaultValue: true, + paramVisibility: 'user-only', + mode: 'trigger', + condition: scopedCondition(triggerId, 'document'), + }, + { + id: 'triggerFolderOid', + title: 'Folder OID', + type: 'short-input', + placeholder: 'OR:wt.folder.SubFolder:5012381', + description: 'Subscribe to documents in this folder', + required: scopedCondition(triggerId, 'folder'), + paramVisibility: 'user-only', + mode: 'trigger', + condition: scopedCondition(triggerId, 'folder'), + }, + { + id: 'triggerContainerOid', + title: 'Container OID', + type: 'short-input', + placeholder: 'OR:wt.pdmlink.PDMLinkProduct:79638', + description: 'Subscribe to documents in this product, library, or other container', + required: scopedCondition(triggerId, 'container'), + paramVisibility: 'user-only', + mode: 'trigger', + condition: scopedCondition(triggerId, 'container'), + }, + ] + + if (options.lifecycleState) { + fields.push({ + id: 'triggerLifecycleStateValue', + title: 'Lifecycle State Value', + type: 'short-input', + placeholder: 'RELEASED', + description: 'Internal lifecycle state value that should trigger the workflow', + required: true, + paramVisibility: 'user-only', + mode: 'trigger', + condition: triggerCondition(triggerId), + }) + } + + if (options.customEvent) { + fields.push({ + id: 'triggerEvent', + title: 'Event Identifier', + type: 'short-input', + placeholder: 'EDIT_CONTENT', + description: + "Exact event identifier returned by EventMgmt/GetApplicableEvents(EntityName='PTC.DocMgmt.Document')", + required: true, + paramVisibility: 'user-only', + mode: 'trigger', + condition: triggerCondition(triggerId), + }) + } + + return fields +} diff --git a/packages/deployment-config/src/integrations.json b/packages/deployment-config/src/integrations.json index 4170c2b0681..c39d9ea21f9 100644 --- a/packages/deployment-config/src/integrations.json +++ b/packages/deployment-config/src/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-10-02", + "updatedAt": "2026-10-03", "integrations": [ { "type": "onepassword", @@ -27343,8 +27343,29 @@ } ], "operationCount": 27, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "windchill_document_attributes_changed", + "name": "Windchill Document Attributes Changed", + "description": "Trigger a workflow when Windchill document attributes change" + }, + { + "id": "windchill_document_identity_changed", + "name": "Windchill Document Identity Changed", + "description": "Trigger a workflow when a Windchill document identity changes" + }, + { + "id": "windchill_document_lifecycle_state_changed", + "name": "Windchill Document Lifecycle State Changed", + "description": "Trigger a workflow when a Windchill document enters a selected lifecycle state" + }, + { + "id": "windchill_custom_document_event", + "name": "Windchill Custom Document Event", + "description": "Trigger a workflow for an installed Windchill document event" + } + ], + "triggerCount": 4, "authType": "api-key", "category": "tools", "integrationType": "documents",