Skip to content

Commit a2842cf

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(integrations): align Dynamics paging and lifecycle contracts
1 parent 25ba372 commit a2842cf

10 files changed

Lines changed: 343 additions & 45 deletions

File tree

apps/docs/content/docs/en/integrations/microsoft_dynamics_365.mdx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Query and list records from a Microsoft Dataverse table. Supports OData query op
4747
| `expand` | string | No | Navigation properties to expand \(OData $expand\) |
4848
| `count` | string | No | Set to "true" to include total record count in response \(OData $count\) |
4949
| `nextLink` | string | No | Exact nextLink returned by a previous page of this operation |
50+
| `nextPageSize` | number | No | Exact nextPageSize returned alongside nextLink by the previous page |
5051

5152
#### Output
5253

@@ -57,6 +58,7 @@ Query and list records from a Microsoft Dataverse table. Supports OData query op
5758
| `totalCount` | number | Provider-reported matching-record count, which Dataverse may cap \(requires $count=true\) |
5859
| `totalCountLimitExceeded` | boolean | Whether Dataverse capped the provider-reported matching-record count |
5960
| `nextLink` | string | URL for the next page of results |
61+
| `nextPageSize` | number | Page size that must accompany nextLink on the continuation request |
6062
| `success` | boolean | Operation success status |
6163

6264
### Get Microsoft Dynamics 365 CRM Record
@@ -201,8 +203,8 @@ Close a Dynamics 365 Sales opportunity as won or lost.
201203
| `environmentUrl` | string | Yes | Dynamics 365 environment URL \(e.g., https://myorg.crm.dynamics.com\) |
202204
| `opportunityId` | string | Yes | GUID of the opportunity to close |
203205
| `outcome` | string | Yes | Opportunity outcome: won or lost |
204-
| `subject` | string | Yes | Subject for the opportunity-close activity \(maximum 200 characters\) |
205-
| `description` | string | No | Optional description for the opportunity-close activity |
206+
| `subject` | string | No | Optional subject for the opportunity-close activity \(maximum 200 characters\) |
207+
| `description` | string | No | Optional description for the opportunity-close activity \(maximum 2,000 characters\) |
206208
| `statusReason` | number | No | Opportunity status-reason value \(defaults to 3 for won or 4 for lost\) |
207209

208210
#### Output
@@ -225,7 +227,7 @@ Resolve and close a Dynamics 365 Customer Service case.
225227
| `environmentUrl` | string | Yes | Dynamics 365 environment URL \(e.g., https://myorg.crm.dynamics.com\) |
226228
| `caseId` | string | Yes | GUID of the case to close |
227229
| `subject` | string | Yes | Subject for the case-resolution activity \(maximum 200 characters\) |
228-
| `description` | string | No | Optional description for the case-resolution activity |
230+
| `description` | string | No | Optional description for the case-resolution activity \(maximum 100,000 characters\) |
229231
| `timeSpent` | number | No | Optional nonnegative number of minutes spent resolving the case |
230232
| `statusReason` | number | No | Resolved case status-reason value \(default: 5\) |
231233

apps/sim/blocks/blocks/microsoft_dynamics_365.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ describe('MicrosoftDynamics365Block', () => {
147147
maxResults: '100',
148148
includeCount: false,
149149
nextLink: `${BASE_PARAMS.environmentUrl}/api/data/v9.2/accounts?$skiptoken=opaque`,
150+
nextPageSize: '100',
150151
data: '{"stale":true}',
151152
leadId: 'stale-lead',
152153
})
@@ -160,6 +161,7 @@ describe('MicrosoftDynamics365Block', () => {
160161
pageSize: 100,
161162
count: 'false',
162163
nextLink: `${BASE_PARAMS.environmentUrl}/api/data/v9.2/accounts?$skiptoken=opaque`,
164+
nextPageSize: 100,
163165
})
164166

165167
expect(
@@ -205,6 +207,36 @@ describe('MicrosoftDynamics365Block', () => {
205207
expect(mapParams({ operation: 'list_records', recordType: 'account' })).toMatchObject({
206208
pageSize: 100,
207209
})
210+
expect(
211+
mapParams({
212+
operation: 'list_records',
213+
recordType: 'account',
214+
nextLink: `${BASE_PARAMS.environmentUrl}/api/data/v9.2/accounts?$skiptoken=opaque`,
215+
nextPageSize: '25',
216+
})
217+
).toEqual({
218+
...BASE_PARAMS,
219+
entitySetName: 'accounts',
220+
count: 'false',
221+
nextLink: `${BASE_PARAMS.environmentUrl}/api/data/v9.2/accounts?$skiptoken=opaque`,
222+
nextPageSize: 25,
223+
})
224+
expect(() =>
225+
mapParams({
226+
operation: 'list_records',
227+
recordType: 'account',
228+
nextLink: `${BASE_PARAMS.environmentUrl}/api/data/v9.2/accounts?$skiptoken=opaque`,
229+
})
230+
).toThrow('Next page size is required when Next Page URL is provided')
231+
expect(() =>
232+
mapParams({
233+
operation: 'list_records',
234+
recordType: 'account',
235+
maxResults: '100',
236+
nextLink: `${BASE_PARAMS.environmentUrl}/api/data/v9.2/accounts?$skiptoken=opaque`,
237+
nextPageSize: '25',
238+
})
239+
).toThrow('Max results must match Next page size')
208240
expect(
209241
mapParams({
210242
operation: 'search_records',
@@ -243,14 +275,15 @@ describe('MicrosoftDynamics365Block', () => {
243275
operation: 'list_owners',
244276
ownerType: 'team',
245277
nextLink: `${BASE_PARAMS.environmentUrl}/api/data/v9.2/teams?$skiptoken=opaque`,
278+
nextPageSize: '100',
246279
})
247280
).toEqual({
248281
...BASE_PARAMS,
249282
entitySetName: 'teams',
250283
select: 'teamid,name,teamtype',
251-
pageSize: 100,
252284
filter: 'teamtype ne 1',
253285
nextLink: `${BASE_PARAMS.environmentUrl}/api/data/v9.2/teams?$skiptoken=opaque`,
286+
nextPageSize: 100,
254287
})
255288
})
256289

@@ -436,6 +469,54 @@ describe('MicrosoftDynamics365Block', () => {
436469
).toThrow('Time spent must be at most 2147483647')
437470
})
438471

472+
it('supports an optional opportunity subject and enforces close-description limits', () => {
473+
expect(
474+
mapParams({
475+
operation: 'close_opportunity',
476+
closeOpportunityId: '11111111-1111-4111-8111-111111111111',
477+
opportunityOutcome: 'won',
478+
})
479+
).toEqual({
480+
...BASE_PARAMS,
481+
opportunityId: '11111111-1111-4111-8111-111111111111',
482+
outcome: 'won',
483+
})
484+
expect(
485+
mapParams({
486+
operation: 'close_opportunity',
487+
closeOpportunityId: '11111111-1111-4111-8111-111111111111',
488+
opportunityOutcome: 'lost',
489+
opportunityDescription: 'x'.repeat(2_000),
490+
})
491+
).toMatchObject({ description: 'x'.repeat(2_000) })
492+
expect(() =>
493+
mapParams({
494+
operation: 'close_opportunity',
495+
closeOpportunityId: '11111111-1111-4111-8111-111111111111',
496+
opportunityDescription: 'x'.repeat(2_001),
497+
})
498+
).toThrow('Close notes must be at most 2000 characters')
499+
expect(
500+
mapParams({
501+
operation: 'close_case',
502+
caseId: '22222222-2222-4222-8222-222222222222',
503+
caseSubject: 'Issue resolved',
504+
caseDescription: 'x'.repeat(100_000),
505+
})
506+
).toMatchObject({ description: 'x'.repeat(100_000) })
507+
expect(() =>
508+
mapParams({
509+
operation: 'close_case',
510+
caseId: '22222222-2222-4222-8222-222222222222',
511+
caseSubject: 'Issue resolved',
512+
caseDescription: 'x'.repeat(100_001),
513+
})
514+
).toThrow('Resolution notes must be at most 100000 characters')
515+
expect(
516+
MicrosoftDynamics365Block.subBlocks.find(({ id }) => id === 'opportunitySubject')?.required
517+
).toBeUndefined()
518+
})
519+
439520
it('declares only outputs returned by the reused and lifecycle tools', () => {
440521
expect(Object.keys(MicrosoftDynamics365Block.outputs)).toEqual([
441522
'records',
@@ -445,6 +526,7 @@ describe('MicrosoftDynamics365Block', () => {
445526
'totalCount',
446527
'totalCountLimitExceeded',
447528
'nextLink',
529+
'nextPageSize',
448530
'results',
449531
'facets',
450532
'createdEntities',

apps/sim/blocks/blocks/microsoft_dynamics_365.ts

Lines changed: 87 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,55 @@ function optionalString(value: unknown): string | undefined {
7171
return trimmed.length > 0 ? trimmed : undefined
7272
}
7373

74+
function optionalOpaqueString(value: unknown): string | undefined {
75+
return typeof value === 'string' && value.length > 0 ? value : undefined
76+
}
77+
78+
function optionalBoundedString(
79+
value: unknown,
80+
label: string,
81+
maxLength: number
82+
): string | undefined {
83+
const normalized = optionalString(value)
84+
if (normalized !== undefined && normalized.length > maxLength) {
85+
throw new Error(`${label} must be at most ${maxLength} characters.`)
86+
}
87+
return normalized
88+
}
89+
90+
function getListPaginationParams(params: Record<string, unknown>) {
91+
const pageSize = parseOptionalNumberInput(params.maxResults, 'Max results', {
92+
integer: true,
93+
min: 1,
94+
max: 100,
95+
})
96+
const nextLink = optionalOpaqueString(params.nextLink)
97+
const nextPageSize = parseOptionalNumberInput(params.nextPageSize, 'Next page size', {
98+
integer: true,
99+
min: 1,
100+
max: 100,
101+
})
102+
103+
if (nextLink !== undefined) {
104+
if (nextPageSize === undefined) {
105+
throw new Error('Next page size is required when Next Page URL is provided.')
106+
}
107+
if (pageSize !== undefined && pageSize !== nextPageSize) {
108+
throw new Error('Max results must match Next page size.')
109+
}
110+
return {
111+
nextLink,
112+
nextPageSize,
113+
...(pageSize !== undefined && { pageSize }),
114+
}
115+
}
116+
117+
if (nextPageSize !== undefined) {
118+
throw new Error('Next page size may only be provided with Next Page URL.')
119+
}
120+
return { pageSize: pageSize ?? 100 }
121+
}
122+
74123
function parseRequiredRecord(value: unknown): Record<string, unknown> {
75124
const parsed = parseOptionalJsonInput<unknown>(value, 'Record data')
76125
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
@@ -344,6 +393,16 @@ export const MicrosoftDynamics365Block: BlockConfig<DataverseResponse> = {
344393
condition: { field: 'operation', value: ['list_records', 'list_owners'] },
345394
mode: 'advanced',
346395
},
396+
{
397+
id: 'nextPageSize',
398+
title: 'Next Page Size',
399+
type: 'short-input',
400+
placeholder: 'Use the nextPageSize output from the previous page',
401+
description:
402+
'Exact continuation page size returned with the previous List Records or List Owners result.',
403+
condition: { field: 'operation', value: ['list_records', 'list_owners'] },
404+
mode: 'advanced',
405+
},
347406
{
348407
id: 'searchTerm',
349408
title: 'Search Term',
@@ -544,14 +603,15 @@ export const MicrosoftDynamics365Block: BlockConfig<DataverseResponse> = {
544603
title: 'Close Subject',
545604
type: 'short-input',
546605
placeholder: 'Reason or summary for closing the opportunity',
606+
description: 'Optional subject for the opportunity-close activity (maximum 200 characters).',
547607
condition: { field: 'operation', value: 'close_opportunity' },
548-
required: { field: 'operation', value: 'close_opportunity' },
549608
},
550609
{
551610
id: 'opportunityDescription',
552611
title: 'Close Notes',
553612
type: 'long-input',
554613
placeholder: 'Optional details about the outcome',
614+
description: 'Optional opportunity-close description (maximum 2,000 characters).',
555615
condition: { field: 'operation', value: 'close_opportunity' },
556616
mode: 'advanced',
557617
},
@@ -584,6 +644,7 @@ export const MicrosoftDynamics365Block: BlockConfig<DataverseResponse> = {
584644
title: 'Resolution Notes',
585645
type: 'long-input',
586646
placeholder: 'Optional resolution details',
647+
description: 'Optional case-resolution description (maximum 100,000 characters).',
587648
condition: { field: 'operation', value: 'close_case' },
588649
mode: 'advanced',
589650
},
@@ -646,12 +707,6 @@ export const MicrosoftDynamics365Block: BlockConfig<DataverseResponse> = {
646707
switch (params.operation) {
647708
case 'list_records': {
648709
const recordType = getRecordType(params.recordType)
649-
const top =
650-
parseOptionalNumberInput(params.maxResults, 'Max results', {
651-
integer: true,
652-
min: 1,
653-
max: 100,
654-
}) ?? 100
655710
const includeCount = parseBooleanWithDefault(
656711
params.includeCount,
657712
'Include total count',
@@ -672,9 +727,8 @@ export const MicrosoftDynamics365Block: BlockConfig<DataverseResponse> = {
672727
...(optionalString(params.recordExpand) && {
673728
expand: optionalString(params.recordExpand),
674729
}),
675-
pageSize: top,
730+
...getListPaginationParams(params),
676731
count: includeCount ? 'true' : 'false',
677-
...(optionalString(params.nextLink) && { nextLink: optionalString(params.nextLink) }),
678732
}
679733
}
680734

@@ -744,19 +798,12 @@ export const MicrosoftDynamics365Block: BlockConfig<DataverseResponse> = {
744798

745799
case 'list_owners': {
746800
const ownerType = getOwnerType(params.ownerType)
747-
const top =
748-
parseOptionalNumberInput(params.maxResults, 'Max results', {
749-
integer: true,
750-
min: 1,
751-
max: 100,
752-
}) ?? 100
753801
return {
754802
...common,
755803
entitySetName: ownerType.entitySetName,
756804
select: ownerType.select,
757805
filter: ownerType.filter,
758-
pageSize: top,
759-
...(optionalString(params.nextLink) && { nextLink: optionalString(params.nextLink) }),
806+
...getListPaginationParams(params),
760807
}
761808
}
762809

@@ -840,14 +887,18 @@ export const MicrosoftDynamics365Block: BlockConfig<DataverseResponse> = {
840887
'Opportunity status reason',
841888
{ integer: true, min: -2_147_483_648, max: 2_147_483_647 }
842889
)
890+
const subject = optionalBoundedString(params.opportunitySubject, 'Close subject', 200)
891+
const description = optionalBoundedString(
892+
params.opportunityDescription,
893+
'Close notes',
894+
2_000
895+
)
843896
return {
844897
...common,
845898
opportunityId: requiredString(params.closeOpportunityId, 'Opportunity ID'),
846899
outcome: requiredString(params.opportunityOutcome ?? 'won', 'Outcome'),
847-
subject: requiredString(params.opportunitySubject, 'Close subject', 200),
848-
...(optionalString(params.opportunityDescription) && {
849-
description: optionalString(params.opportunityDescription),
850-
}),
900+
...(subject && { subject }),
901+
...(description && { description }),
851902
...(statusReason !== undefined && { statusReason }),
852903
}
853904
}
@@ -863,13 +914,16 @@ export const MicrosoftDynamics365Block: BlockConfig<DataverseResponse> = {
863914
'Case status reason',
864915
{ integer: true, min: -2_147_483_648, max: 2_147_483_647 }
865916
)
917+
const description = optionalBoundedString(
918+
params.caseDescription,
919+
'Resolution notes',
920+
100_000
921+
)
866922
return {
867923
...common,
868924
caseId: requiredString(params.caseId, 'Case ID'),
869925
subject: requiredString(params.caseSubject, 'Resolution subject', 200),
870-
...(optionalString(params.caseDescription) && {
871-
description: optionalString(params.caseDescription),
872-
}),
926+
...(description && { description }),
873927
...(timeSpent !== undefined && { timeSpent }),
874928
...(statusReason !== undefined && { statusReason }),
875929
}
@@ -898,6 +952,10 @@ export const MicrosoftDynamics365Block: BlockConfig<DataverseResponse> = {
898952
maxResults: { type: 'string', description: 'Maximum results for a single page (1-100)' },
899953
includeCount: { type: 'boolean', description: 'Whether to request the total matching count' },
900954
nextLink: { type: 'string', description: 'Opaque next-page URL from a previous list result' },
955+
nextPageSize: {
956+
type: 'string',
957+
description: 'Page size paired with the previous list result nextLink',
958+
},
901959
searchTerm: { type: 'string', description: 'Dataverse Search query text' },
902960
searchSkip: { type: 'string', description: 'Number of earlier search results to skip' },
903961
searchMode: { type: 'string', description: 'Search mode: any or all' },
@@ -973,6 +1031,10 @@ export const MicrosoftDynamics365Block: BlockConfig<DataverseResponse> = {
9731031
description: 'Whether Dataverse capped the provider-reported matching count',
9741032
},
9751033
nextLink: { type: 'string', description: 'Opaque provider URL for the next records page' },
1034+
nextPageSize: {
1035+
type: 'number',
1036+
description: 'Page size that must accompany the next records page URL',
1037+
},
9761038
results: {
9771039
type: 'json',
9781040
description: 'Current page of Dataverse Search results with table-specific attributes',
@@ -1117,7 +1179,7 @@ export const MicrosoftDynamics365BlockMeta = {
11171179
description:
11181180
'Close an approved Dynamics 365 opportunity as won or lost with an explicit outcome summary.',
11191181
content:
1120-
'# Close Sales Opportunity\n\nUse this skill only after the user or an authorized workflow step has approved the final outcome.\n\n## Steps\n1. Retrieve the opportunity and confirm its record ID and intended outcome.\n2. Ask for a close subject and optional notes.\n3. Use Close Opportunity with won or lost; supply a custom status-reason integer only when the environment defines it.\n4. Do not retry the close action automatically.\n5. Report the supplied opportunity ID and outcome without claiming a response status Dynamics 365 did not return.\n\n## Output\nThe closed opportunity ID, chosen outcome, and action success.',
1182+
'# Close Sales Opportunity\n\nUse this skill only after the user or an authorized workflow step has approved the final outcome.\n\n## Steps\n1. Retrieve the opportunity and confirm its record ID and intended outcome.\n2. Optionally gather a close subject and notes.\n3. Use Close Opportunity with won or lost; supply a custom status-reason integer only when the environment defines it.\n4. Do not retry the close action automatically.\n5. Report the supplied opportunity ID and outcome without claiming a response status Dynamics 365 did not return.\n\n## Output\nThe closed opportunity ID, chosen outcome, and action success.',
11211183
},
11221184
{
11231185
name: 'resolve-customer-case',

apps/sim/tools/generated/tool-metadata.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/generated/tool-outputs.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)