node-autotask 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/MCP_TOOL_CATALOG.json +156 -1253
  3. package/MCP_TOOL_MANIFEST.md +154 -220
  4. package/MCP_TOOL_OVERRIDES.json +8 -62
  5. package/N8N_MCP_MANIFEST.json +128 -705
  6. package/N8N_MCP_MANIFEST.md +43 -63
  7. package/README.md +10 -28
  8. package/capabilities.json +15315 -22219
  9. package/dist/{base-CVx2VMSj.d.cts → base-BVoYDlVA.d.cts} +2 -1
  10. package/dist/{base-CVx2VMSj.d.ts → base-BVoYDlVA.d.ts} +2 -1
  11. package/dist/capabilities.cjs +2 -2
  12. package/dist/capabilities.d.cts +2 -1
  13. package/dist/capabilities.d.ts +2 -1
  14. package/dist/capabilities.js +1 -1
  15. package/dist/{chunk-5VHSLJMR.js → chunk-A6LHCJQP.js} +30 -9
  16. package/dist/{chunk-JY2BWYXY.js → chunk-ASMCTZO2.js} +15314 -22218
  17. package/dist/{chunk-TH7B5H4P.cjs → chunk-DC3PY4EF.cjs} +1839 -1407
  18. package/dist/{chunk-4EFXIE53.js → chunk-DXKXQHCX.js} +5 -5
  19. package/dist/{chunk-SK3GYRNA.cjs → chunk-JO6WI763.cjs} +143 -143
  20. package/dist/{chunk-Q23JULNN.js → chunk-NSW7JPVS.js} +11529 -13319
  21. package/dist/{chunk-KTEDPS4X.js → chunk-O4PIPUS4.js} +1583 -1151
  22. package/dist/{chunk-MJTKK3QG.cjs → chunk-SN4BMQ2N.cjs} +31 -10
  23. package/dist/{chunk-5BDUMDAQ.cjs → chunk-VXVYFJH3.cjs} +15314 -22218
  24. package/dist/{chunk-VFLAMMIJ.cjs → chunk-YMBVKVK4.cjs} +10604 -12394
  25. package/dist/{index-C7rMJ4IV.d.ts → index-C5g1H_hF.d.ts} +1080 -1288
  26. package/dist/{index-Cm_Xpt7X.d.cts → index-Db0SNnQn.d.cts} +1080 -1288
  27. package/dist/{index-C3KFMnRT.d.cts → index-c549LvOb.d.cts} +332 -331
  28. package/dist/{index-C3KFMnRT.d.ts → index-c549LvOb.d.ts} +332 -331
  29. package/dist/index.cjs +23 -23
  30. package/dist/index.d.cts +15 -41
  31. package/dist/index.d.ts +15 -41
  32. package/dist/index.js +8 -8
  33. package/dist/operations/index.cjs +5 -2234
  34. package/dist/operations/index.d.cts +51 -323
  35. package/dist/operations/index.d.ts +51 -323
  36. package/dist/operations/index.js +30 -2259
  37. package/dist/pool.cjs +9 -9
  38. package/dist/pool.d.cts +4 -3
  39. package/dist/pool.d.ts +4 -3
  40. package/dist/pool.js +5 -5
  41. package/dist/resources/index.cjs +5 -5
  42. package/dist/resources/index.d.cts +2 -2
  43. package/dist/resources/index.d.ts +2 -2
  44. package/dist/resources/index.js +8 -8
  45. package/dist/types/index.d.cts +1 -1
  46. package/dist/types/index.d.ts +1 -1
  47. package/dist/untrusted.cjs +2 -2
  48. package/dist/untrusted.d.cts +14 -5
  49. package/dist/untrusted.d.ts +14 -5
  50. package/dist/untrusted.js +1 -1
  51. package/docs/API.md +321 -334
  52. package/docs/LIVE-TESTING.md +4 -6
  53. package/docs/N8N-ADAPTER.md +26 -26
  54. package/examples/mcp/schemas.ts +2 -0
  55. package/examples/mcp/tools-read.ts +3 -2
  56. package/n8n-mcp.curation.json +3 -3
  57. package/package.json +10 -9
@@ -3,37 +3,47 @@ import {
3
3
  AutotaskError,
4
4
  BaseResource,
5
5
  ConflictError,
6
+ FETCH_BY_IDS_CHUNK_SIZE,
7
+ IN_FILTER_CAP,
8
+ MoveRefusedError,
6
9
  NotFoundError,
7
10
  RELATIONSHIP_MAP,
11
+ ResolutionError,
12
+ ResponseRejectedError,
8
13
  StaleObjectError,
9
14
  TICKET_LABEL_FIELDS,
10
15
  addLabelSibling,
11
16
  addNameSibling,
17
+ budgetExceededError,
12
18
  buildDryRunResult,
19
+ buildUiLink,
20
+ clientOf,
21
+ compositePart,
22
+ compositePartLimit,
13
23
  createNamePlan,
14
24
  cursorToPath,
15
- formatVendorDetail,
25
+ invokeRead,
16
26
  isAutotaskError,
17
27
  isClosedStatus,
28
+ makeCallBudget,
18
29
  mergePartialBody,
30
+ numericIds,
19
31
  openStatusFilter,
32
+ openTicketsOfPart,
20
33
  parseIdentifier,
21
34
  parseVendorErrors,
22
- queryRows,
23
- refuse,
24
35
  resolveCompositeNames,
25
36
  resolveLineOfBusinessChain,
26
37
  resolveTicketStatusSemantics,
27
- runMove,
28
- uiLinkFor,
29
38
  unwrapItem,
30
39
  unwrapItems,
31
40
  unwrapOperation,
41
+ unwrapQueryCount,
32
42
  vendorId
33
- } from "./chunk-Q23JULNN.js";
43
+ } from "./chunk-NSW7JPVS.js";
34
44
  import {
35
45
  CAPABILITIES
36
- } from "./chunk-JY2BWYXY.js";
46
+ } from "./chunk-ASMCTZO2.js";
37
47
 
38
48
  // src/resources/appointments.ts
39
49
  var AppointmentsResource = class extends BaseResource {
@@ -120,6 +130,50 @@ var AuthenticateResource = class {
120
130
  }
121
131
  };
122
132
 
133
+ // src/resources/billing_codes.ts
134
+ var BillingCodesResource = class extends BaseResource {
135
+ constructor(http, pageSize) {
136
+ super(http, {
137
+ key: "billing_codes",
138
+ resourcePath: "/V1.0/BillingCodes",
139
+ deletable: false,
140
+ udfCapable: false,
141
+ searchFields: ["name"]
142
+ }, pageSize);
143
+ }
144
+ /** Query BillingCode records with the G3 filter surface (server-side JSON filter). */
145
+ query(params) {
146
+ return this.queryItems(params);
147
+ }
148
+ /** Get every BillingCode record (the MCP-preferred read; 1000-page guarded). */
149
+ async queryAll(params) {
150
+ return this.queryAllItems(params);
151
+ }
152
+ /** Count matching records (queryCount envelope unwrapped to a plain number). */
153
+ async count(params) {
154
+ return this.countGlobal(params);
155
+ }
156
+ /** Get one record by id (item envelope; item: null → NotFoundError). `fresh: true` bypasses the read-cache lookup and refreshes the entry (issue #250). */
157
+ async get(id, opts = {}) {
158
+ return this.getGlobal(id, opts?.fresh === true);
159
+ }
160
+ /** Resolve a BillingCode from a numeric id (or, where the vendor indexes a primary text field,
161
+ * from an exact match on name). A numeric id miss throws NOT_FOUND; a complete
162
+ * non-match returns null; ambiguity throws RESOLUTION_AMBIGUOUS (candidate ids) and a
163
+ * cap-tripped bounded client scan throws RESOLUTION_TRUNCATED (never a silent null).
164
+ * The compact shape is the structural alias of the full record; `expand: true` is the
165
+ * type-level escape hatch. */
166
+ async resolve(identifier, opts = {}) {
167
+ void opts;
168
+ return this.resolveBase(identifier);
169
+ }
170
+ /** Search BillingCode records: bounded contains on name (no vendor free-text search exists);
171
+ * limit defaults to 25, max 500. */
172
+ async search(query, opts) {
173
+ return this.searchBase(query, { limit: opts?.limit });
174
+ }
175
+ };
176
+
123
177
  // src/resources/billing_item_approval_levels.ts
124
178
  var BillingItemApprovalLevelsResource = class extends BaseResource {
125
179
  constructor(http, pageSize) {
@@ -1096,7 +1150,8 @@ async function fetchRecord(http, path, label, readOpts) {
1096
1150
  }
1097
1151
  return body.item;
1098
1152
  }
1099
- async function moveConfigurationItem(_resource, sourceId, opts, http) {
1153
+ async function moveConfigurationItem(_resource, sourceId, opts) {
1154
+ const http = _resource.http;
1100
1155
  const numericSource = parseIdentifier(sourceId, "sourceId");
1101
1156
  if (opts?.destinationCompanyId === void 0) {
1102
1157
  throw new AutotaskConfigError("moveConfigurationItem: destinationCompanyId is required");
@@ -1205,7 +1260,8 @@ async function ciScopedQuery(http, path, itemID, limit) {
1205
1260
  });
1206
1261
  return unwrapItems(res).items;
1207
1262
  }
1208
- async function profile(resource, id, opts = {}, http) {
1263
+ async function profile(resource, id, opts = {}) {
1264
+ const http = resource.http;
1209
1265
  const noteLimit = profilePartLimit(opts.noteLimit, "noteLimit");
1210
1266
  const relatedLimit = profilePartLimit(opts.relatedLimit, "relatedLimit");
1211
1267
  const ci = await resource.get(id);
@@ -1333,11 +1389,11 @@ var ConfigurationItemsResource = class extends BaseResource {
1333
1389
  }
1334
1390
  /** Node-parity special op (hand-written in src/resources/special/configuration_items.ts). */
1335
1391
  moveConfigurationItem(sourceId, opts) {
1336
- return moveConfigurationItem(this, sourceId, opts, this.http);
1392
+ return moveConfigurationItem(this, sourceId, opts);
1337
1393
  }
1338
1394
  /** Node-parity special op (hand-written in src/resources/special/configuration_items.ts). */
1339
1395
  profile(id, opts) {
1340
- return profile(this, id, opts, this.http);
1396
+ return profile(this, id, opts);
1341
1397
  }
1342
1398
  };
1343
1399
 
@@ -1691,7 +1747,8 @@ async function fetchRecord2(http, path, label, readOpts) {
1691
1747
  }
1692
1748
  return body.item;
1693
1749
  }
1694
- async function moveToCompany(_resource, sourceContactId, destinationCompanyId, opts = {}, http) {
1750
+ async function moveToCompany(_resource, sourceContactId, destinationCompanyId, opts = {}) {
1751
+ const http = _resource.http;
1695
1752
  const sourceId = parseIdentifier(sourceContactId, "sourceContactId");
1696
1753
  const destId = parseIdentifier(destinationCompanyId, "destinationCompanyId");
1697
1754
  const source = await fetchRecord2(http, `/V1.0/Contacts/${sourceId}`, `Contact ${sourceId}`, { maxRetries: opts.maxRetries });
@@ -1781,153 +1838,28 @@ async function moveToCompany(_resource, sourceContactId, destinationCompanyId, o
1781
1838
  }
1782
1839
  return { sourceId, newId, destinationCompanyId: destId, created: true, sourceDeleted };
1783
1840
  }
1784
- var OP_UPSERT_EMAIL = "contacts.upsertByEmail";
1785
- var UPSERT_MAX_DISCOVERY_MERGES = 3;
1786
- var EMAIL_SHAPE = /^.+@.+/;
1787
- var SAFE_CONTACT_CREATE_DEFAULTS = {};
1788
- var DEDUP_LIMIT = 10;
1789
- var CompanyNotFoundError = class extends AutotaskError {
1790
- constructor(message, options = {}) {
1791
- super(message, {
1792
- code: "COMPANY_NOT_FOUND",
1793
- category: "not_found",
1794
- suggestedAction: "Resolve the company id (companies.get on a known id, or a ticket contactID/companyID via tickets.fullContext) and retry.",
1795
- ...options
1796
- });
1797
- this.name = "CompanyNotFoundError";
1798
- }
1799
- };
1800
- var ContactCreateFailedError = class extends AutotaskError {
1801
- /** The vendor-named fields (empty when the 500 body named nothing parseable). */
1802
- vendorFields;
1803
- constructor(message, vendorFields, options = {}) {
1804
- super(message, { code: "CONTACT_CREATE_FAILED", category: "validation", ...options });
1805
- this.name = "ContactCreateFailedError";
1806
- this.vendorFields = vendorFields;
1807
- }
1808
- };
1809
- async function upsertByEmail(resource, companyID, email, extra, opts, http) {
1810
- const cid = parseIdentifier(companyID, "companyID");
1811
- if (typeof email !== "string" || !EMAIL_SHAPE.test(email)) {
1812
- throw new AutotaskConfigError(
1813
- `upsertByEmail: email must be a non-empty string with the @ shape (a non-empty local part + a non-empty domain; got ${JSON.stringify(email)})`,
1814
- { operation: OP_UPSERT_EMAIL }
1815
- );
1816
- }
1817
- if (extra !== void 0) {
1818
- if (extra.isActive !== void 0 && typeof extra.isActive !== "boolean" && extra.isActive !== 0 && extra.isActive !== 1) {
1819
- throw new AutotaskConfigError(
1820
- `upsertByEmail: extra.isActive must be a boolean (mapped to the zone-correct number 1/0) or a raw 0/1 number (got ${String(extra.isActive)})`,
1821
- { operation: OP_UPSERT_EMAIL }
1822
- );
1823
- }
1824
- if (extra.firstName !== void 0 && (typeof extra.firstName !== "string" || extra.firstName === "")) {
1825
- throw new AutotaskConfigError("upsertByEmail: extra.firstName must be a non-empty string (or omitted \u2014 the node defaults it to the email local-part)", { operation: OP_UPSERT_EMAIL });
1826
- }
1827
- if (extra.lastName !== void 0 && typeof extra.lastName !== "string") {
1828
- throw new AutotaskConfigError("upsertByEmail: extra.lastName must be a string (or omitted \u2014 the node defaults it to the non-empty neutral value Unknown)", { operation: OP_UPSERT_EMAIL });
1829
- }
1830
- }
1831
- const parentRes = await http.request({ method: "GET", path: `/V1.0/Companies/${cid}`, operation: OP_UPSERT_EMAIL, maxRetries: opts.maxRetries });
1832
- const parentBody = parentRes;
1833
- if (parentBody === null || !("item" in parentBody) || parentBody.item === null || parentBody.item === void 0) {
1834
- throw new CompanyNotFoundError(
1835
- `upsertByEmail: company ${cid} was not found (companies.get returned item: null \u2014 the companies count/query surface is blind to this API user; get is the authority)`,
1836
- { resourceIds: [cid], details: { idRefs: [{ entity: "company", id: cid }] }, operation: OP_UPSERT_EMAIL }
1837
- );
1838
- }
1839
- const q = await resource.queryBounded({
1840
- filter: [
1841
- { field: "emailAddress", op: "eq", value: email },
1842
- { field: "companyID", op: "eq", value: cid }
1843
- ],
1844
- limit: DEDUP_LIMIT
1845
- });
1846
- const match = q.items[0];
1847
- if (match !== void 0) {
1848
- if (opts.dryRun === true) {
1849
- return buildDryRunResult(
1850
- {
1851
- operation: OP_UPSERT_EMAIL,
1852
- method: "POST",
1853
- path: `/V1.0/Companies/${cid}/Contacts`,
1854
- ids: [cid],
1855
- checks: [
1856
- { name: "input-validation", ok: true },
1857
- { name: "parent-exists", ok: true },
1858
- { name: "duplicate-detection", ok: true }
1859
- ],
1860
- impact: { affected: 0, scope: "no write \u2014 the contact already exists (dedup match; the create is skipped)", reversible: true },
1861
- warnings: [`EXISTS: a contact with the address is already on company ${cid} (id ${Number(match.id)}) \u2014 the apply would skip the create (zero mutating wire).`],
1862
- data: { reason: "EXISTS", id: Number(match.id) }
1863
- },
1864
- { redactPersonalData: http.redactPersonalData }
1865
- );
1866
- }
1867
- return { id: Number(match.id), created: false, existing: match };
1868
- }
1869
- const localPart = email.split("@")[0];
1870
- const isActive = extra?.isActive === void 0 ? true : extra.isActive;
1871
- const body = {
1872
- emailAddress: email,
1873
- companyID: cid,
1874
- firstName: extra?.firstName ?? localPart,
1875
- lastName: extra?.lastName ?? "Unknown",
1876
- isActive: typeof isActive === "boolean" ? isActive ? 1 : 0 : isActive
1877
- };
1878
- if (opts.dryRun === true) {
1879
- return buildDryRunResult(
1880
- {
1881
- operation: OP_UPSERT_EMAIL,
1882
- method: "POST",
1883
- path: `/V1.0/Companies/${cid}/Contacts`,
1884
- ids: [cid],
1885
- checks: [
1886
- { name: "input-validation", ok: true },
1887
- { name: "parent-exists", ok: true },
1888
- { name: "duplicate-detection", ok: true }
1889
- ],
1890
- impact: { affected: 1, scope: "single-record", reversible: true },
1891
- data: body
1892
- },
1893
- { redactPersonalData: http.redactPersonalData }
1894
- );
1895
- }
1896
- let merges = 0;
1897
- for (; ; ) {
1898
- try {
1899
- const created = await resource.create(cid, body, { maxRetries: opts.maxRetries });
1900
- return { id: created.id, created: true };
1901
- } catch (err) {
1902
- if (!isAutotaskError(err) || err.vendorError === void 0) throw err;
1903
- const named = (parseVendorErrors(err.vendorError) ?? []).map((e) => e.field).filter((f) => f !== void 0);
1904
- const seen = /* @__PURE__ */ new Set();
1905
- const unique = [...named].filter((f) => seen.has(f) ? false : (seen.add(f), true));
1906
- const hasDefault = unique.some((f) => SAFE_CONTACT_CREATE_DEFAULTS[f] !== void 0);
1907
- if (hasDefault && merges < UPSERT_MAX_DISCOVERY_MERGES) {
1908
- for (const f of unique) {
1909
- if (body[f] === void 0 && SAFE_CONTACT_CREATE_DEFAULTS[f] !== void 0) {
1910
- body[f] = SAFE_CONTACT_CREATE_DEFAULTS[f];
1911
- merges += 1;
1912
- break;
1913
- }
1914
- }
1915
- continue;
1916
- }
1917
- const detail = formatVendorDetail(err.vendorError);
1918
- throw new ContactCreateFailedError(
1919
- `upsertByEmail: the vendor rejected the contact create${unique.length > 0 ? ` naming field(s) ${unique.join(", ")}` : ""} (no proven-safe default \u2014 the required set is fully filled by the node)${detail ? ` \u2014 ${detail}` : ""}`,
1920
- unique,
1921
- {
1922
- operation: OP_UPSERT_EMAIL,
1923
- httpStatus: err.httpStatus,
1924
- vendorError: err.vendorError,
1925
- resourceIds: [cid],
1926
- details: { idRefs: [{ entity: "company", id: cid }] }
1927
- }
1928
- );
1929
- }
1930
- }
1841
+ var FULL_PROFILE_OP = "contacts.fullProfile";
1842
+ async function fullProfile(resource, id, opts = {}) {
1843
+ const client = clientOf(resource, FULL_PROFILE_OP);
1844
+ const ticketLimit = compositePartLimit(opts.ticketLimit, "ticketLimit", FULL_PROFILE_OP);
1845
+ const contactId = parseIdentifier(id, "id");
1846
+ const envelope = await invokeRead(client, "contacts.query", [
1847
+ { filter: [{ field: "id", op: "eq", value: contactId }], limit: 1 }
1848
+ ]);
1849
+ const row = (envelope.items ?? [])[0];
1850
+ if (row === void 0) {
1851
+ throw new NotFoundError(`Contact ${contactId} not found`, { operation: "contacts.get", httpStatus: 404 });
1852
+ }
1853
+ const fk = typeof row.companyID === "number" && row.companyID > 0 ? row.companyID : null;
1854
+ const root = fk === null ? row : await invokeRead(client, "contacts.get", [fk, contactId]);
1855
+ const company = fk === null ? { value: null, absent: "NO_FK" } : await compositePart(client, "companies.get", [fk]);
1856
+ const openTickets = await openTicketsOfPart(
1857
+ client,
1858
+ "contactID",
1859
+ contactId,
1860
+ ticketLimit
1861
+ );
1862
+ return { root, parts: { company, open_tickets: openTickets } };
1931
1863
  }
1932
1864
 
1933
1865
  // src/resources/contacts.ts
@@ -2014,11 +1946,11 @@ var ContactsResource = class extends BaseResource {
2014
1946
  }
2015
1947
  /** Node-parity special op (hand-written in src/resources/special/contacts.ts). */
2016
1948
  moveToCompany(sourceContactId, destinationCompanyId, opts = {}) {
2017
- return moveToCompany(this, sourceContactId, destinationCompanyId, opts, this.http);
1949
+ return moveToCompany(this, sourceContactId, destinationCompanyId, opts);
2018
1950
  }
2019
1951
  /** Node-parity special op (hand-written in src/resources/special/contacts.ts). */
2020
- upsertByEmail(companyID, email, extra, opts = {}) {
2021
- return upsertByEmail(this, companyID, email, extra, opts, this.http);
1952
+ fullProfile(id, opts) {
1953
+ return fullProfile(this, id, opts);
2022
1954
  }
2023
1955
  };
2024
1956
 
@@ -3861,7 +3793,8 @@ var PATH_SUFFIX = {
3861
3793
  remove: "StockedItemsRemove",
3862
3794
  transfer: "StockedItemsTransfer"
3863
3795
  };
3864
- async function stockedItems(_resource, kind, parentId, data, opts = {}, http) {
3796
+ async function stockedItems(_resource, kind, parentId, data, opts = {}) {
3797
+ const http = _resource.http;
3865
3798
  const numericParent = parseIdentifier(parentId, "parentId");
3866
3799
  const suffix = PATH_SUFFIX[kind];
3867
3800
  if (opts.dryRun === true) {
@@ -3954,15 +3887,15 @@ var InventoryProductsResource = class extends BaseResource {
3954
3887
  }
3955
3888
  /** Node-parity special op (hand-written in src/resources/special/inventory_products.ts). */
3956
3889
  stockedItemsAdd(parentId, data, opts = {}) {
3957
- return stockedItems(this, "add", parentId, data, opts, this.http);
3890
+ return stockedItems(this, "add", parentId, data, opts);
3958
3891
  }
3959
3892
  /** Node-parity special op (hand-written in src/resources/special/inventory_products.ts). */
3960
3893
  stockedItemsRemove(parentId, data, opts = {}) {
3961
- return stockedItems(this, "remove", parentId, data, opts, this.http);
3894
+ return stockedItems(this, "remove", parentId, data, opts);
3962
3895
  }
3963
3896
  /** Node-parity special op (hand-written in src/resources/special/inventory_products.ts). */
3964
3897
  stockedItemsTransfer(parentId, data, opts = {}) {
3965
- return stockedItems(this, "transfer", parentId, data, opts, this.http);
3898
+ return stockedItems(this, "transfer", parentId, data, opts);
3966
3899
  }
3967
3900
  };
3968
3901
 
@@ -5000,6 +4933,19 @@ var QuoteLocationsResource = class extends BaseResource {
5000
4933
  }
5001
4934
  };
5002
4935
 
4936
+ // src/resources/special/quotes.ts
4937
+ var OP = "quotes.withItems";
4938
+ async function withItems(resource, id, opts = {}) {
4939
+ const client = clientOf(resource, OP);
4940
+ const itemLimit = compositePartLimit(opts.itemLimit, "itemLimit", OP);
4941
+ const quoteId = parseIdentifier(id, "id");
4942
+ const root = await invokeRead(client, "quotes.get", [quoteId]);
4943
+ const items = await compositePart(client, "quote_items.query", [
4944
+ { filter: [{ field: "quoteID", op: "eq", value: quoteId }], limit: itemLimit }
4945
+ ]);
4946
+ return { root, parts: { items } };
4947
+ }
4948
+
5003
4949
  // src/resources/quotes.ts
5004
4950
  var QuotesResource = class extends BaseResource {
5005
4951
  constructor(http, pageSize) {
@@ -5065,6 +5011,10 @@ var QuotesResource = class extends BaseResource {
5065
5011
  async search(query, opts) {
5066
5012
  return this.searchBase(query, { limit: opts?.limit });
5067
5013
  }
5014
+ /** Node-parity special op (hand-written in src/resources/special/quotes.ts). */
5015
+ withItems(id, opts) {
5016
+ return withItems(this, id, opts);
5017
+ }
5068
5018
  };
5069
5019
 
5070
5020
  // src/resources/quote_templates.ts
@@ -5263,8 +5213,152 @@ var ResourceSkillsResource = class extends BaseResource {
5263
5213
  }
5264
5214
  };
5265
5215
 
5216
+ // src/resources/special/resources.ts
5217
+ async function whoAmI(_resource, userName) {
5218
+ const http = _resource.http;
5219
+ const user = userName.trim();
5220
+ if (user === "") throw new AutotaskConfigError("whoAmI: the configured UserName is empty");
5221
+ const value = user.includes("@") ? user.split("@")[0] ?? "" : user;
5222
+ const res = await http.request({
5223
+ method: "POST",
5224
+ idempotent: true,
5225
+ path: "/V1.0/Resources/query",
5226
+ body: { MaxRecords: 2, filter: [{ field: "userName", op: "eq", value }] },
5227
+ operation: "resources.whoAmI"
5228
+ });
5229
+ const matches = unwrapItems(res).items;
5230
+ if (matches.length === 0) return null;
5231
+ if (matches.length > 1) {
5232
+ const ids = matches.map((m) => m.id).filter((id) => typeof id === "number");
5233
+ throw new ResolutionError(
5234
+ `whoAmI: "${value}" is ambiguous (matched ${matches.length} resources)`,
5235
+ { code: "RESOLUTION_AMBIGUOUS", resourceIds: ids, details: { idRefs: ids.map((cid) => ({ entity: "resource", id: cid })) } }
5236
+ );
5237
+ }
5238
+ return matches[0];
5239
+ }
5240
+ var WORKLOAD_OP = "resources.workload";
5241
+ async function workload(resource, id, opts = {}) {
5242
+ const client = clientOf(resource, WORKLOAD_OP);
5243
+ const ticketLimit = compositePartLimit(opts.ticketLimit, "ticketLimit", WORKLOAD_OP);
5244
+ const taskLimit = compositePartLimit(opts.taskLimit, "taskLimit", WORKLOAD_OP);
5245
+ const timeOffLimit = compositePartLimit(opts.timeOffLimit, "timeOffLimit", WORKLOAD_OP);
5246
+ const resourceId = parseIdentifier(id, "id");
5247
+ const root = await invokeRead(client, "resources.get", [resourceId]);
5248
+ const strict = { allowAssumed: false };
5249
+ const joined = await compositePart(client, "tickets.forResource", [
5250
+ resourceId,
5251
+ { openOnly: true, limit: Math.min(ticketLimit, 500), ...strict }
5252
+ ]);
5253
+ const counted = await compositePart(client, "tickets.forResource", [resourceId, { countOnly: true, ...strict }]);
5254
+ const assignments = await compositePart(client, "service_call_task_resources.query", [
5255
+ { filter: [{ field: "resourceID", op: "eq", value: resourceId }], limit: taskLimit }
5256
+ ]);
5257
+ const balances = await compositePart(client, "resource_time_off_balances.getByResource", [resourceId]);
5258
+ const timeOff = await compositePart(client, "time_off_requests.query", [
5259
+ { filter: [{ field: "resourceID", op: "eq", value: resourceId }], limit: timeOffLimit }
5260
+ ]);
5261
+ const openTickets = joined.value !== null && typeof joined.value === "object" ? counted.value !== null && typeof counted.value === "object" ? (() => {
5262
+ const { primary, secondary, both, open, capped } = counted.value;
5263
+ return {
5264
+ ...joined,
5265
+ value: { ...joined.value, totals: { primary, secondary, both, open, capped } }
5266
+ };
5267
+ })() : {
5268
+ ...joined,
5269
+ value: { ...joined.value, totalsAbsent: counted.absent ?? "NO_TOTALS" }
5270
+ } : joined;
5271
+ const taskIds = numericIds(
5272
+ assignments.value?.items ?? [],
5273
+ "serviceCallTaskID"
5274
+ ).slice(0, IN_FILTER_CAP);
5275
+ const taskDetails = taskIds.length === 0 ? { value: null, absent: "NO_ASSIGNMENTS" } : await compositePart(client, "service_call_tasks.query", [
5276
+ { filter: [{ field: "taskID", op: "in", value: taskIds }], limit: taskLimit }
5277
+ ]);
5278
+ return {
5279
+ root,
5280
+ parts: {
5281
+ open_tickets: openTickets,
5282
+ task_assignments: assignments,
5283
+ task_details: taskDetails,
5284
+ time_off_balances: balances,
5285
+ time_off_requests: timeOff
5286
+ }
5287
+ };
5288
+ }
5289
+
5290
+ // src/resources/resources.ts
5291
+ var ResourcesResource = class extends BaseResource {
5292
+ clientConfig;
5293
+ constructor(http, clientConfig, pageSize) {
5294
+ super(http, {
5295
+ key: "resources",
5296
+ resourcePath: "/V1.0/Resources",
5297
+ deletable: false,
5298
+ udfCapable: false,
5299
+ collectionUpdate: true,
5300
+ // D1: the spec's item path is GET-only — confirmed updates PUT the collection (id in the body)
5301
+ searchFields: ["firstName", "lastName"]
5302
+ }, pageSize);
5303
+ this.clientConfig = clientConfig;
5304
+ }
5305
+ /** Query Resource records with the G3 filter surface (server-side JSON filter). */
5306
+ query(params) {
5307
+ return this.queryItems(params);
5308
+ }
5309
+ /** Get every Resource record (the MCP-preferred read; 1000-page guarded). */
5310
+ async queryAll(params) {
5311
+ return this.queryAllItems(params);
5312
+ }
5313
+ /** Count matching records (queryCount envelope unwrapped to a plain number). */
5314
+ async count(params) {
5315
+ return this.countGlobal(params);
5316
+ }
5317
+ /** Get one record by id (item envelope; item: null → NotFoundError). `fresh: true` bypasses the read-cache lookup and refreshes the entry (issue #250). */
5318
+ async get(id, opts = {}) {
5319
+ return this.getGlobal(id, opts?.fresh === true);
5320
+ }
5321
+ /** Update a record (PUT on the COLLECTION — the vendored spec's item path is GET-only; the vendor
5322
+ * keys the record by the id in the body). The vendor validates the FULL model on this route (V2), so a
5323
+ * partial body is merged into the CURRENT record first: one fresh GET (which also serves the
5324
+ * stale-object guard's read) then the collection PUT of the merged model — an explicit null in the
5325
+ * caller body sends null; an omitted field keeps the current value. `raw: true` in the opts bag sends
5326
+ * the body as-is (full model or intentional partial — the compatibility escape hatch; no merge read).
5327
+ * `dryRun: true` returns the pinned envelope whose `data` is the merged body (the exact confirmed
5328
+ * wire) with the best-effort diff, from that same single read; a caller-supplied lastModifiedDate that
5329
+ * mismatches the vendor's current value throws STALE_OBJECT before the mutation. */
5330
+ async update(id, data, opts = {}) {
5331
+ if (opts.dryRun === true) return this.runUpdateDryRun(id, data, opts);
5332
+ return this.updateGlobal(id, data, opts);
5333
+ }
5334
+ /** Resolve a Resource from a numeric id (or, where the vendor indexes a primary text field,
5335
+ * from an exact match on firstName / lastName). A numeric id miss throws NOT_FOUND; a complete
5336
+ * non-match returns null; ambiguity throws RESOLUTION_AMBIGUOUS (candidate ids) and a
5337
+ * cap-tripped bounded client scan throws RESOLUTION_TRUNCATED (never a silent null).
5338
+ * The compact shape is the structural alias of the full record; `expand: true` is the
5339
+ * type-level escape hatch. */
5340
+ async resolve(identifier, opts = {}) {
5341
+ void opts;
5342
+ return this.resolveBase(identifier);
5343
+ }
5344
+ /** Search Resource records: bounded contains on firstName + lastName (no vendor free-text search exists);
5345
+ * limit defaults to 25, max 500. */
5346
+ async search(query, opts) {
5347
+ return this.searchBase(query, { limit: opts?.limit });
5348
+ }
5349
+ /** Node-parity special op (hand-written in src/resources/special/resources.ts). */
5350
+ whoAmI() {
5351
+ return whoAmI(this, this.clientConfig.userName);
5352
+ }
5353
+ /** Node-parity special op (hand-written in src/resources/special/resources.ts). */
5354
+ workload(id, opts) {
5355
+ return workload(this, id, opts);
5356
+ }
5357
+ };
5358
+
5266
5359
  // src/resources/special/resource_time_off_additional.ts
5267
- async function getByResource(_resource, resourceId, http) {
5360
+ async function getByResource(_resource, resourceId) {
5361
+ const http = _resource.http;
5268
5362
  const numericResource = parseIdentifier(resourceId, "resourceId");
5269
5363
  const res = await http.request({
5270
5364
  method: "GET",
@@ -5317,7 +5411,7 @@ var ResourceTimeOffAdditionalResource = class extends BaseResource {
5317
5411
  }
5318
5412
  /** Node-parity special op (hand-written in src/resources/special/resource_time_off_additional.ts). */
5319
5413
  getByResource(resourceId) {
5320
- return getByResource(this, resourceId, this.http);
5414
+ return getByResource(this, resourceId);
5321
5415
  }
5322
5416
  };
5323
5417
 
@@ -5364,7 +5458,8 @@ var ResourceTimeOffApproversResource = class extends BaseResource {
5364
5458
  };
5365
5459
 
5366
5460
  // src/resources/special/resource_time_off_balances.ts
5367
- async function getByResource2(_resource, resourceId, http) {
5461
+ async function getByResource2(_resource, resourceId) {
5462
+ const http = _resource.http;
5368
5463
  const numericResource = parseIdentifier(resourceId, "resourceId");
5369
5464
  const res = await http.request({
5370
5465
  method: "GET",
@@ -5373,7 +5468,8 @@ async function getByResource2(_resource, resourceId, http) {
5373
5468
  });
5374
5469
  return unwrapItems(res, "resource_time_off_balances.getByResource").items;
5375
5470
  }
5376
- async function getByYear(_resource, resourceId, year, http) {
5471
+ async function getByYear(_resource, resourceId, year) {
5472
+ const http = _resource.http;
5377
5473
  const numericResource = parseIdentifier(resourceId, "resourceId");
5378
5474
  if (!Number.isSafeInteger(year) || year < 1) {
5379
5475
  throw new AutotaskConfigError("getByYear: year must be a positive integer");
@@ -5416,11 +5512,11 @@ var ResourceTimeOffBalancesResource = class extends BaseResource {
5416
5512
  }
5417
5513
  /** Node-parity special op (hand-written in src/resources/special/resource_time_off_balances.ts). */
5418
5514
  getByResource(resourceId) {
5419
- return getByResource2(this, resourceId, this.http);
5515
+ return getByResource2(this, resourceId);
5420
5516
  }
5421
5517
  /** Node-parity special op (hand-written in src/resources/special/resource_time_off_balances.ts). */
5422
5518
  getByYear(resourceId, year) {
5423
- return getByYear(this, resourceId, year, this.http);
5519
+ return getByYear(this, resourceId, year);
5424
5520
  }
5425
5521
  };
5426
5522
 
@@ -5534,6 +5630,37 @@ var SalesOrdersResource = class extends BaseResource {
5534
5630
  }
5535
5631
  };
5536
5632
 
5633
+ // src/resources/special/service_calls.ts
5634
+ var OP2 = "service_calls.fullProfile";
5635
+ async function fullProfile2(resource, id, opts = {}) {
5636
+ const client = clientOf(resource, OP2);
5637
+ const ticketLimit = compositePartLimit(opts.ticketLimit, "ticketLimit", OP2);
5638
+ const taskLimit = compositePartLimit(opts.taskLimit, "taskLimit", OP2);
5639
+ const callId = parseIdentifier(id, "id");
5640
+ const root = await invokeRead(client, "service_calls.get", [callId]);
5641
+ const tickets = await compositePart(client, "service_call_tickets.query", [
5642
+ { filter: [{ field: "serviceCallID", op: "eq", value: callId }], limit: ticketLimit }
5643
+ ]);
5644
+ const tasks = await compositePart(client, "service_call_tasks.query", [
5645
+ { filter: [{ field: "serviceCallID", op: "eq", value: callId }], limit: taskLimit }
5646
+ ]);
5647
+ const taskIds = numericIds(
5648
+ tasks.value?.items ?? [],
5649
+ "taskID"
5650
+ ).slice(0, IN_FILTER_CAP);
5651
+ const resourceAssignments = taskIds.length === 0 ? { value: null, absent: "NO_TASKS" } : await compositePart(client, "service_call_task_resources.query", [
5652
+ { filter: [{ field: "taskID", op: "in", value: taskIds }], limit: taskLimit }
5653
+ ]);
5654
+ return {
5655
+ root,
5656
+ parts: {
5657
+ tickets,
5658
+ tasks,
5659
+ resource_assignments: resourceAssignments
5660
+ }
5661
+ };
5662
+ }
5663
+
5537
5664
  // src/resources/service_calls.ts
5538
5665
  var ServiceCallsResource = class extends BaseResource {
5539
5666
  constructor(http, pageSize) {
@@ -5601,6 +5728,10 @@ var ServiceCallsResource = class extends BaseResource {
5601
5728
  void opts;
5602
5729
  return this.resolveBase(identifier);
5603
5730
  }
5731
+ /** Node-parity special op (hand-written in src/resources/special/service_calls.ts). */
5732
+ fullProfile(id, opts) {
5733
+ return fullProfile2(this, id, opts);
5734
+ }
5604
5735
  };
5605
5736
 
5606
5737
  // src/resources/service_call_task_resources.ts
@@ -6893,23 +7024,203 @@ var TicketChangeRequestApprovalsResource = class extends BaseResource {
6893
7024
  }
6894
7025
  };
6895
7026
 
7027
+ // src/resources/special/_shared/move-engine.ts
7028
+ import { randomUUID } from "crypto";
7029
+ var MOVE_SOURCE_AGE_DEFAULT_DAYS = 14;
7030
+ var MOVE_EFFECTS_NOTE = "Create then delete: the new record gets a fresh create date and creator; creating/deleting may trigger notifications, SLA and workflow rules the API cannot suppress; vendor acceptance of the create is not validated by a dry run";
7031
+ var refuse = (code, message, ids, operation, extra = {}) => new MoveRefusedError(message, { code, operation, details: { status: "failed", ...ids, ...extra } });
7032
+ async function queryRows(http, resource, filter, operation, retries) {
7033
+ const res = await http.request({ method: "POST", idempotent: true, path: `/V1.0/${resource}/query`, body: { MaxRecords: 5, filter }, operation, maxRetries: retries });
7034
+ return unwrapItems(res).items;
7035
+ }
7036
+ function sameValue(a, b) {
7037
+ if (a === null || a === void 0 || a === "") return b === null || b === void 0 || b === "";
7038
+ if (b === null || b === void 0 || b === "") return false;
7039
+ if (typeof a === "number" || typeof b === "number") return Math.abs(Number(a) - Number(b)) < 1e-9;
7040
+ if (typeof a === "boolean" || typeof b === "boolean") return a === b;
7041
+ if (typeof a === "string" && typeof b === "string") {
7042
+ const iso2 = /^\d{4}-\d{2}-\d{2}T/;
7043
+ if (iso2.test(a) && iso2.test(b)) return Date.parse(a) === Date.parse(b);
7044
+ return a.trim() === b.trim();
7045
+ }
7046
+ return JSON.stringify(a) === JSON.stringify(b);
7047
+ }
7048
+ async function readTicket(http, id, retries, operation) {
7049
+ try {
7050
+ const res = await http.request({ method: "GET", path: `/V1.0/Tickets/${id}`, operation, maxRetries: retries });
7051
+ const item = res?.item;
7052
+ return item ?? null;
7053
+ } catch (e) {
7054
+ if (isAutotaskError(e) && (e.httpStatus === 404 || e.code === "NOT_FOUND")) return null;
7055
+ throw e;
7056
+ }
7057
+ }
7058
+ async function preview(http, spec, sourceId, toTicketId, opts) {
7059
+ const op = spec.operation;
7060
+ const ids = { sourceId, toTicketId };
7061
+ const source = await spec.readSource(http, sourceId, opts.maxRetries);
7062
+ if (source === null) throw refuse("MOVE_SOURCE_NOT_FOUND", `${op}: source ${spec.entity} ${sourceId} does not exist`, ids, op);
7063
+ const sourceTicketId = spec.sourceTicketId(source);
7064
+ if (typeof sourceTicketId !== "number") {
7065
+ throw refuse("MOVE_SOURCE_UNSUPPORTED", `${op}: source ${spec.entity} ${sourceId} is not attached to a ticket (task and internal records are not moved)`, ids, op);
7066
+ }
7067
+ if (sourceTicketId === toTicketId) throw refuse("MOVE_TARGET_SAME", `${op}: target ticket ${toTicketId} is the source's current ticket`, ids, op);
7068
+ if (opts.lastModifiedDate !== void 0) {
7069
+ const current = source.lastModifiedDateTime ?? source.lastModifiedDate;
7070
+ if (current !== opts.lastModifiedDate) {
7071
+ throw refuse("STALE_OBJECT", `${op}: source ${spec.entity} ${sourceId} was modified since the snapshot`, ids, op);
7072
+ }
7073
+ }
7074
+ const cap = opts.maxSourceAgeDays === void 0 ? MOVE_SOURCE_AGE_DEFAULT_DAYS : Math.min(MOVE_SOURCE_AGE_DEFAULT_DAYS, opts.maxSourceAgeDays);
7075
+ if (!Number.isFinite(cap) || cap < 0) throw new AutotaskConfigError(`${op}: maxSourceAgeDays must be a number >= 0`);
7076
+ const when = Date.parse(String(source[spec.dateField] ?? ""));
7077
+ if (!Number.isFinite(when) || Date.now() - when > cap * 864e5) {
7078
+ throw refuse("MOVE_SOURCE_TOO_OLD", `${op}: source ${spec.entity} ${sourceId} is older than ${cap} day(s) (or undated) \u2014 move it in the Autotask UI`, ids, op, { manualAction: manualFor(http, spec, source, "older than the move age limit") });
7079
+ }
7080
+ const target = await readTicket(http, toTicketId, opts.maxRetries, op);
7081
+ if (target === null) throw refuse("MOVE_TARGET_MISSING", `${op}: target ticket ${toTicketId} does not exist`, ids, op);
7082
+ const closedStatus = await resolveTicketStatusSemantics(http);
7083
+ if (isClosedStatus(target.status, closedStatus.closedIds) || target.completedDate !== void 0 && target.completedDate !== null) {
7084
+ throw refuse(
7085
+ "MOVE_TARGET_CLOSED",
7086
+ `${op}: target ticket ${toTicketId} is closed (status in [${closedStatus.closedIds.join(", ")}] or completed) \u2014 closed ids ${closedStatus.source === "assumed" ? "ASSUMED (the tenant status picklist could not be read)" : "resolved from the tenant Tickets status picklist"}`,
7087
+ ids,
7088
+ op,
7089
+ { closedStatusIds: closedStatus.closedIds, closedStatusSource: closedStatus.source }
7090
+ );
7091
+ }
7092
+ const origin = await readTicket(http, sourceTicketId, opts.maxRetries, op);
7093
+ const facts = {
7094
+ closedStatus: { closedIds: closedStatus.closedIds, source: closedStatus.source, note: closedStatus.note },
7095
+ sourceTicketId,
7096
+ sourceCompanyID: origin?.companyID ?? null,
7097
+ targetCompanyID: target.companyID ?? null,
7098
+ companyChanged: origin === null ? null : origin.companyID !== target.companyID,
7099
+ contractChanged: origin === null ? null : (origin.contractID ?? null) !== (target.contractID ?? null)
7100
+ };
7101
+ const ctx = { http, spec, sourceId, source, sourceTicketId, toTicketId, facts, retries: opts.maxRetries };
7102
+ await spec.checkSource(ctx);
7103
+ const sameScope = facts.companyChanged === false && facts.contractChanged === false;
7104
+ const payload = spec.buildPayload(source, toTicketId, { sameScope });
7105
+ await assertNoTwin(ctx, payload);
7106
+ return { ctx, payload };
7107
+ }
7108
+ function manualFor(http, spec, source, reason) {
7109
+ return { action: "delete_source", uiLink: spec.manualLink(http, source), reason };
7110
+ }
7111
+ async function assertNoTwin(ctx, payload) {
7112
+ const q = ctx.spec.twinQuery(payload);
7113
+ const rows = await queryRows(ctx.http, q.resource, q.filter, ctx.spec.operation, ctx.retries);
7114
+ if (rows.length > 0) {
7115
+ throw refuse("MOVE_TARGET_HAS_TWIN", `${ctx.spec.operation}: target ticket ${ctx.toTicketId} already has an equivalent ${ctx.spec.entity} (id ${String(rows[0].id)}) \u2014 a duplicate or resumed move`, { sourceId: ctx.sourceId, toTicketId: ctx.toTicketId }, ctx.spec.operation, { twinId: rows[0].id });
7116
+ }
7117
+ }
7118
+ var statusOf = (e) => isAutotaskError(e) ? e.httpStatus : void 0;
7119
+ async function runMove(http, spec, sourceIdIn, toTicketIn, opts = {}) {
7120
+ const op = spec.operation;
7121
+ const sourceId = Number(parseIdentifier(sourceIdIn, "sourceId"));
7122
+ const toTicketId = Number(parseIdentifier(toTicketIn, "toTicketId"));
7123
+ if (!Number.isSafeInteger(sourceId) || !Number.isSafeInteger(toTicketId)) {
7124
+ throw new AutotaskConfigError(`${op}: sourceId and toTicketId must be safe integers`);
7125
+ }
7126
+ const { ctx, payload } = await preview(http, spec, sourceId, toTicketId, opts);
7127
+ if (opts.dryRun === true) {
7128
+ return buildDryRunResult(
7129
+ {
7130
+ operation: op,
7131
+ method: "POST",
7132
+ path: spec.createPath(toTicketId),
7133
+ ids: [sourceId],
7134
+ checks: [
7135
+ { name: "source-exists", ok: true },
7136
+ { name: "target-open", ok: true },
7137
+ { name: "invariants", ok: true },
7138
+ { name: "no-twin-on-target", ok: true }
7139
+ ],
7140
+ impact: { affected: 2, scope: "create-then-delete", reversible: false },
7141
+ warnings: [MOVE_EFFECTS_NOTE, ...ctx.facts.companyChanged === true ? ["the target ticket belongs to a different company"] : []],
7142
+ data: payload
7143
+ },
7144
+ { redactPersonalData: http.redactPersonalData }
7145
+ );
7146
+ }
7147
+ const correlationId = randomUUID();
7148
+ const ids = { sourceId, toTicketId, correlationId };
7149
+ await assertNoTwin(ctx, payload);
7150
+ let newId;
7151
+ try {
7152
+ const created = await http.request({ method: "POST", path: spec.createPath(toTicketId), body: payload, operation: op, maxRetries: 0 });
7153
+ newId = Number(unwrapOperation(created, op).id);
7154
+ } catch (e) {
7155
+ const status = statusOf(e);
7156
+ if (status !== void 0 && status >= 400 && status < 500) {
7157
+ throw new MoveRefusedError(`${op}: the vendor refused the create \u2014 nothing changed`, { code: "MOVE_CREATE_REFUSED", operation: op, httpStatus: status, details: { status: "failed", ...ids } });
7158
+ }
7159
+ throw new MoveRefusedError(`${op}: the create outcome is unknown (never resent) \u2014 check the target ticket for a new ${spec.entity} before retrying; the twin check blocks a second create`, { code: "MOVE_UNKNOWN", operation: op, details: { status: "unknown", ...ids } });
7160
+ }
7161
+ const rollback = async (code, message) => {
7162
+ try {
7163
+ await http.request({ method: "DELETE", path: spec.deletePath(newId, toTicketId), operation: op, maxRetries: 0 });
7164
+ } catch (re) {
7165
+ const unreadable = statusOf(re) === void 0 || (statusOf(re) ?? 0) >= 500;
7166
+ throw new MoveRefusedError(`${op}: ${message}; rolling back the new ${spec.entity} ${newId} FAILED \u2014 both records exist`, { code: unreadable ? "MOVE_UNKNOWN" : "MOVE_PARTIAL", operation: op, details: { status: unreadable ? "unknown" : "partial", ...ids, newId, reason: code } });
7167
+ }
7168
+ throw new MoveRefusedError(`${op}: ${message}; the new ${spec.entity} ${newId} was rolled back, the source is unchanged`, { code, operation: op, details: { status: "failed", ...ids, newId } });
7169
+ };
7170
+ const fresh = await spec.readById(http, newId, toTicketId).catch(() => null);
7171
+ if (fresh === null) return rollback("MOVE_VERIFY_FAILED", "the new record could not be read back");
7172
+ const mismatched = [...spec.verifyFields, "ticketID"].filter((f) => !sameValue(f === "ticketID" ? toTicketId : payload[f], fresh[f]));
7173
+ if (mismatched.length > 0) return rollback("MOVE_VERIFY_FAILED", `the read-back differs on ${mismatched.join(", ")}`);
7174
+ const again = await spec.readSource(http, sourceId, void 0).catch(() => null);
7175
+ if (again === null) {
7176
+ return { status: "moved_source_absent", sourceId, newId, correlationId, snapshot: ctx.source, facts: ctx.facts };
7177
+ }
7178
+ const changed = [...spec.guardFields].filter((f) => !sameValue(ctx.source[f], again[f]));
7179
+ if (changed.length > 0) return rollback("MOVE_SOURCE_CHANGED", `the source changed after the preview (${changed.join(", ")})`);
7180
+ try {
7181
+ await http.request({ method: "DELETE", path: spec.deletePath(sourceId, ctx.sourceTicketId), operation: op, maxRetries: 0 });
7182
+ } catch (e) {
7183
+ const status = statusOf(e);
7184
+ if (status === 404) return { status: "moved_source_absent", sourceId, newId, correlationId, snapshot: ctx.source, facts: ctx.facts };
7185
+ if (status !== void 0 && status >= 400 && status < 500) {
7186
+ return {
7187
+ status: "moved_manual_delete",
7188
+ sourceId,
7189
+ newId,
7190
+ correlationId,
7191
+ snapshot: ctx.source,
7192
+ facts: ctx.facts,
7193
+ manualAction: manualFor(http, spec, ctx.source, `the vendor refused to delete the source (HTTP ${status}); the verified copy exists on the target \u2014 delete the source in the Autotask UI`)
7194
+ };
7195
+ }
7196
+ if (status === void 0) {
7197
+ throw new MoveRefusedError(`${op}: the source delete outcome is unknown (never resent) \u2014 re-read the source: absent means the move completed`, { code: "MOVE_UNKNOWN", operation: op, details: { status: "unknown", ...ids, newId } });
7198
+ }
7199
+ return rollback("MOVE_SOURCE_DELETE_FAILED", `the source delete failed (HTTP ${status})`);
7200
+ }
7201
+ return { status: "moved", sourceId, newId, correlationId, snapshot: ctx.source, facts: ctx.facts };
7202
+ }
7203
+ function uiLinkFor(http, entity, id) {
7204
+ return buildUiLink({ entity, id, baseUrl: http.baseUrl }) ?? null;
7205
+ }
7206
+
6896
7207
  // src/resources/special/ticket_charges.ts
6897
- var OP = "ticket_charges.moveToTicket";
7208
+ var OP3 = "ticket_charges.moveToTicket";
6898
7209
  var COPY = ["name", "description", "chargeType", "billingCodeID", "productID", "unitQuantity", "unitCost", "unitPrice", "datePurchased", "isBillableToCompany", "notes", "purchaseOrderNumber", "internalPurchaseOrderNumber"];
6899
7210
  var CONTRACT_SCOPED = ["contractServiceID", "contractServiceBundleID"];
6900
7211
  async function readCharge(http, id, retries) {
6901
- const rows = await queryRows(http, "TicketCharges", [{ field: "id", op: "eq", value: id }], OP, retries);
7212
+ const rows = await queryRows(http, "TicketCharges", [{ field: "id", op: "eq", value: id }], OP3, retries);
6902
7213
  return rows[0] ?? null;
6903
7214
  }
6904
7215
  async function checkSource({ http, source, sourceId, sourceTicketId, toTicketId }) {
6905
7216
  if (source.isBilled === true) {
6906
- throw refuse("MOVE_BLOCKED_BILLED", `${OP}: ticket charge ${sourceId} is billed \u2014 move it in the Autotask UI`, { sourceId, toTicketId }, OP, {
7217
+ throw refuse("MOVE_BLOCKED_BILLED", `${OP3}: ticket charge ${sourceId} is billed \u2014 move it in the Autotask UI`, { sourceId, toTicketId }, OP3, {
6907
7218
  manualAction: { action: "delete_source", uiLink: uiLinkFor(http, "tickets", sourceTicketId), reason: "billed charges are not moved by the API (the ticket link opens its charges)" }
6908
7219
  });
6909
7220
  }
6910
7221
  }
6911
7222
  var SPEC = {
6912
- operation: OP,
7223
+ operation: OP3,
6913
7224
  entity: "ticket_charge",
6914
7225
  readSource: readCharge,
6915
7226
  sourceTicketId: (s) => typeof s.ticketID === "number" ? s.ticketID : null,
@@ -6940,8 +7251,8 @@ var SPEC = {
6940
7251
  // charges have no ExecuteCommand code: the parent ticket is the closest clickable target
6941
7252
  manualLink: (http, source) => typeof source.ticketID === "number" ? uiLinkFor(http, "tickets", source.ticketID) : null
6942
7253
  };
6943
- function moveToTicket(_resource, sourceId, toTicketId, opts, http) {
6944
- return runMove(http, SPEC, sourceId, toTicketId, opts);
7254
+ function moveToTicket(resource, sourceId, toTicketId, opts) {
7255
+ return runMove(resource.http, SPEC, sourceId, toTicketId, opts);
6945
7256
  }
6946
7257
 
6947
7258
  // src/resources/ticket_charges.ts
@@ -7015,7 +7326,7 @@ var TicketChargesResource = class extends BaseResource {
7015
7326
  }
7016
7327
  /** Node-parity special op (hand-written in src/resources/special/ticket_charges.ts). */
7017
7328
  moveToTicket(sourceId, toTicketId, opts = {}) {
7018
- return moveToTicket(this, sourceId, toTicketId, opts, this.http);
7329
+ return moveToTicket(this, sourceId, toTicketId, opts);
7019
7330
  }
7020
7331
  };
7021
7332
 
@@ -7438,765 +7749,246 @@ var TicketSecondaryResourcesResource = class extends BaseResource {
7438
7749
  }
7439
7750
  };
7440
7751
 
7441
- // src/resources/special/tickets/common.ts
7442
- async function fetchTicket(http, id, operation) {
7443
- const res = await http.request({
7444
- method: "GET",
7445
- path: `/V1.0/Tickets/${id}`,
7446
- operation
7447
- });
7448
- const body = res;
7449
- if (body === null || !("item" in body)) {
7450
- throw new NotFoundError(`Ticket ${id} not found (unexpected envelope shape)`, { resourceIds: [id], details: { idRefs: [{ entity: "ticket", id }] } });
7752
+ // src/resources/special/tickets/errors.ts
7753
+ var StatusUnknownError = class extends AutotaskError {
7754
+ /** The live picklist size (the message names it; the values + labels are via operations.fieldCatalog with labels: true). */
7755
+ picklistCount;
7756
+ constructor(message, options = {}) {
7757
+ super(message, {
7758
+ code: "STATUS_UNKNOWN",
7759
+ category: "validation",
7760
+ suggestedAction: `Pass one of the tenant's live status values (discover the current values + labels with operations.fieldCatalog({ resources: ["tickets"], fields: ["status"], labels: true })).`,
7761
+ ...options
7762
+ });
7763
+ this.name = "StatusUnknownError";
7764
+ this.picklistCount = options.picklistCount ?? 0;
7451
7765
  }
7452
- if (body.item === null || body.item === void 0) throw new NotFoundError(`Ticket ${id} not found`, { resourceIds: [id], details: { idRefs: [{ entity: "ticket", id }] } });
7453
- return body.item;
7454
- }
7455
- async function ticketQueryItems(http, path, filter, maxRecords, operation) {
7456
- const res = await http.request({
7457
- method: "POST",
7458
- idempotent: true,
7459
- path,
7460
- body: { MaxRecords: maxRecords, filter },
7461
- operation
7462
- });
7463
- return unwrapItems(res).items;
7464
- }
7465
- function truncate(text, limit) {
7466
- const full = text ?? "";
7467
- if (full.length <= limit) return { value: full, truncated: false, length: full.length };
7468
- return { value: `${full.slice(0, limit)}\u2026`, truncated: true, length: full.length };
7469
- }
7766
+ };
7767
+ var StatusFieldsRequiredError = class extends AutotaskError {
7768
+ vendorFields;
7769
+ currentStatus;
7770
+ targetStatus;
7771
+ currentValues;
7772
+ constructor(message, details, options = {}) {
7773
+ super(message, { code: "STATUS_FIELDS_REQUIRED", category: "validation", suggestedAction: "Pass the required field value(s) in the `fields` argument and retry (the node never fills a field without a proven-safe default).", ...options });
7774
+ this.name = "StatusFieldsRequiredError";
7775
+ this.vendorFields = details.vendorFields;
7776
+ this.currentStatus = details.currentStatus;
7777
+ this.targetStatus = details.targetStatus;
7778
+ this.currentValues = details.currentValues;
7779
+ }
7780
+ };
7470
7781
 
7471
- // src/resources/special/tickets/childRead.ts
7472
- function isRouteUnavailable(err) {
7473
- return isAutotaskError(err) && (err.httpStatus === 404 || err.httpStatus === 500);
7782
+ // src/resources/special/tickets/writeSupport.ts
7783
+ var OP_STATUS = "tickets.changeStatus";
7784
+ var MAX_DISCOVERY_MERGES = 3;
7785
+ function isPositiveInt(v) {
7786
+ return typeof v === "number" && Number.isInteger(v) && v > 0;
7474
7787
  }
7475
- function routeFact(route, path, err) {
7476
- return {
7477
- route,
7478
- path,
7479
- httpStatus: isAutotaskError(err) && typeof err.httpStatus === "number" ? err.httpStatus : null
7480
- };
7788
+ function namedVendorFields(err) {
7789
+ if (!isAutotaskError(err) || err.vendorError === void 0) return [];
7790
+ const entries = parseVendorErrors(err.vendorError) ?? [];
7791
+ const out = /* @__PURE__ */ new Set();
7792
+ for (const e of entries) if (e.field !== void 0) out.add(e.field);
7793
+ return [...out];
7481
7794
  }
7482
- function childDeclaresQuery(resourceKey) {
7483
- return CAPABILITIES.some(
7484
- (c) => c.id === `${resourceKey}.query` && (c.status === "implemented" || c.status === "tested")
7485
- );
7795
+ async function fetchItem(http, path, operation, readOpts) {
7796
+ const res = await http.request({ method: "GET", path, operation, maxRetries: readOpts?.maxRetries });
7797
+ return unwrapItemOrNull(res);
7486
7798
  }
7487
- function planScopedPath(resourceKey, ticketId) {
7488
- if (!childDeclaresQuery(resourceKey)) return null;
7489
- const entry = RELATIONSHIP_MAP[resourceKey];
7490
- const parent = entry === void 0 ? null : entry.parent;
7491
- if (parent === null || parent.resource !== "tickets" || parent.segment === null || parent.field === null) return null;
7492
- const parentPath = RELATIONSHIP_MAP[parent.resource]?.path;
7493
- if (parentPath === null || parentPath === void 0) return null;
7494
- return `/V1.0/${parentPath}/${ticketId}/${parent.segment}`;
7799
+ async function fetchItemUnredacted(http, path, operation, readOpts) {
7800
+ const res = await http.request({ method: "GET", path, operation, skipResponseRedaction: true, maxRetries: readOpts?.maxRetries });
7801
+ return unwrapItemOrNull(res);
7495
7802
  }
7496
- function scopedRowMatches(row, filter) {
7497
- let conds;
7498
- if (Array.isArray(filter)) conds = filter;
7499
- else if (filter !== null && typeof filter === "object" && Array.isArray(filter.filter)) {
7500
- conds = filter.filter;
7501
- }
7502
- if (conds === void 0) return true;
7503
- for (const raw of conds) {
7504
- if (raw === null || typeof raw !== "object") return false;
7505
- const cond = raw;
7506
- if (typeof cond.field !== "string" || typeof cond.op !== "string") return false;
7507
- if (cond.field === "ticketID") continue;
7508
- const value = row[cond.field];
7509
- if (typeof value !== "string") return false;
7510
- const want = String(cond.value ?? "");
7511
- if (cond.op === "eq" && value !== want) return false;
7512
- if (cond.op === "gt" && !(value > want)) return false;
7513
- if (cond.op === "lt" && !(value < want)) return false;
7514
- if (cond.op !== "eq" && cond.op !== "gt" && cond.op !== "lt") return false;
7803
+ function unwrapItemOrNull(res) {
7804
+ const body = res;
7805
+ if (body === null || !("item" in body) || body.item === null || body.item === void 0) return null;
7806
+ return body.item;
7807
+ }
7808
+ function discoveryMerge(err, body, defaults) {
7809
+ if (!isAutotaskError(err) || err.vendorError === void 0) return null;
7810
+ const entries = parseVendorErrors(err.vendorError);
7811
+ if (entries === void 0) return null;
7812
+ for (const entry of entries) {
7813
+ const field = entry.field;
7814
+ if (field === void 0 || body[field] !== void 0) continue;
7815
+ if (defaults[field] === void 0) continue;
7816
+ return { field, value: defaults[field] };
7515
7817
  }
7516
- return true;
7818
+ return null;
7517
7819
  }
7518
- var SCOPED_FALLBACK_PAGE_CAP = 10;
7519
- async function scopedCollectionRead(http, startPath, limit, operation, accept) {
7520
- let target = startPath;
7521
- const items = [];
7522
- let guard = false;
7523
- let pages = 0;
7524
- let nextCursor = null;
7525
- for (; ; ) {
7526
- if (pages >= SCOPED_FALLBACK_PAGE_CAP) {
7527
- guard = true;
7528
- break;
7820
+
7821
+ // src/resources/special/tickets/changeStatus.ts
7822
+ var STATUS_REQUIRED_FIELDS = {
7823
+ // <discovery output lands here — e.g. 8: { requiredFields: ['dueDateTime'], safeDefaults: { dueDateTime: '<now>' } }>
7824
+ };
7825
+ async function readLiveStatusSet(resource) {
7826
+ const raw = await resource.getFieldInfo();
7827
+ const row = raw.find((f) => f.name === "status");
7828
+ const values = row !== void 0 && row.isPickList === true ? row.picklistValues : void 0;
7829
+ const ids = /* @__PURE__ */ new Set();
7830
+ if (Array.isArray(values)) {
7831
+ for (const pv of values) {
7832
+ const n = Number(pv.value);
7833
+ if (Number.isFinite(n)) ids.add(n);
7529
7834
  }
7530
- const body = await http.request({ method: "GET", path: target, operation });
7531
- const { items: pageItems, pageDetails } = unwrapItems(body, operation);
7532
- nextCursor = pageDetails.nextPageUrl;
7533
- pages += 1;
7534
- const window = pageItems.slice(0, Math.max(0, limit - items.length));
7535
- for (const row of window) if (accept(row)) items.push(row);
7536
- if (items.length >= limit) break;
7537
- if (nextCursor === null || pageItems.length === 0) break;
7538
- target = cursorToPath(http.baseUrl, nextCursor);
7539
7835
  }
7540
- const capped = items.length >= limit || guard && nextCursor !== null;
7541
- return { items, capped };
7836
+ return ids;
7542
7837
  }
7543
- async function ticketChildRead(http, spec, ticketId) {
7838
+ var GUARD_DEGRADED_WARNING = "the tenant's live status picklist metadata was unavailable (the local guard is degraded for this call \u2014 the W-A4 wall or a metadata failure); the vendor's own validation is the final authority on the status value";
7839
+ async function guardTargetStatus(resource, http, targetStatus) {
7840
+ let liveStatus = null;
7544
7841
  try {
7545
- const items = await ticketQueryItems(http, spec.globalPath, spec.filter, spec.limit, spec.operation);
7546
- return { items, capped: items.length >= spec.limit, status: "ok", routeFacts: [] };
7842
+ const ids = await readLiveStatusSet(resource);
7843
+ if (ids.size > 0) liveStatus = ids;
7547
7844
  } catch (err) {
7548
- if (!isRouteUnavailable(err)) throw err;
7549
- const facts = [routeFact("global", spec.globalPath, err)];
7550
- const scopedPath = planScopedPath(spec.resourceKey, ticketId);
7551
- if (scopedPath === null) return { items: [], capped: false, status: "miss", routeFacts: facts };
7845
+ if (!isAutotaskError(err) || err.code === "ABORTED") throw err;
7846
+ }
7847
+ if (liveStatus !== null && !liveStatus.has(targetStatus)) {
7848
+ http.fieldCache?.invalidate("tickets:");
7849
+ let freshStatus = null;
7850
+ let reReadFailed = false;
7552
7851
  try {
7553
- const read = await scopedCollectionRead(http, scopedPath, spec.limit, spec.operation, (row) => scopedRowMatches(row, spec.filter));
7554
- return { ...read, status: "ok-scoped", routeFacts: [] };
7555
- } catch (scopedErr) {
7556
- facts.push(routeFact("scoped", scopedPath, scopedErr));
7557
- return { items: [], capped: false, status: "miss", routeFacts: facts };
7852
+ const freshIds = await readLiveStatusSet(resource);
7853
+ if (freshIds.size > 0) freshStatus = freshIds;
7854
+ } catch (err) {
7855
+ if (!isAutotaskError(err) || err.code === "ABORTED") throw err;
7856
+ freshStatus = null;
7857
+ reReadFailed = true;
7858
+ }
7859
+ if (freshStatus !== null && freshStatus.has(targetStatus)) {
7860
+ liveStatus = freshStatus;
7861
+ } else {
7862
+ const verdict = freshStatus ?? liveStatus;
7863
+ const staleNote = freshStatus === null ? `; the live re-read ${reReadFailed ? "failed" : "returned no values"}, so this picklist count is from the initial read (up to one field-cache TTL old)` : "";
7864
+ throw new StatusUnknownError(
7865
+ `changeStatus: targetStatus ${targetStatus} is not in the tenant's live status picklist (live: ${verdict.size} values \u2014 the entity metadata is the oracle; the values + labels are discoverable with operations.fieldCatalog({ resources: ["tickets"], fields: ["status"], labels: true })${staleNote})`,
7866
+ { operation: OP_STATUS, picklistCount: verdict.size }
7867
+ );
7558
7868
  }
7559
7869
  }
7870
+ return { guardDegraded: liveStatus === null };
7560
7871
  }
7561
- function applyRouteStatus(part, read) {
7562
- if (read.status === "ok-scoped") part.route = "scoped";
7563
- if (read.status === "miss") part.routeFacts = read.routeFacts;
7564
- }
7565
-
7566
- // src/resources/special/tickets/sla.ts
7567
- var MS = 864e5;
7568
- var SLA_PAGE = 100;
7569
- async function slaHealthCheck(_resource, id, opts = {}, http) {
7570
- void opts;
7571
- const ticketId = parseIdentifier(id, "id");
7572
- await fetchTicket(http, ticketId, "tickets.slaHealthCheck");
7573
- const rows = await ticketQueryItems(
7574
- http,
7575
- "/V1.0/ServiceLevelAgreementResults/query",
7576
- [{ field: "ticketID", op: "eq", value: ticketId }],
7577
- SLA_PAGE,
7578
- "tickets.slaHealthCheck"
7872
+ function noChangeDryRun(http, ticketId, targetStatus, guardDegraded) {
7873
+ return buildDryRunResult(
7874
+ {
7875
+ operation: OP_STATUS,
7876
+ method: "PUT",
7877
+ path: "/V1.0/Tickets",
7878
+ ids: [ticketId],
7879
+ checks: [
7880
+ { name: "status-known", ok: true },
7881
+ { name: "merge-read", ok: true },
7882
+ { name: "no-op", ok: true }
7883
+ ],
7884
+ impact: { affected: 0, scope: "single-record", reversible: true },
7885
+ warnings: [
7886
+ ...guardDegraded ? [GUARD_DEGRADED_WARNING] : [],
7887
+ `NO_CHANGE: the ticket is already in the target status ${targetStatus} \u2014 the apply would be a zero-wire no-op.`
7888
+ ],
7889
+ data: { reason: "NO_CHANGE", status: targetStatus }
7890
+ },
7891
+ { redactPersonalData: http.redactPersonalData }
7579
7892
  );
7580
- const slas = rows.map((row) => {
7581
- const bool = (v) => typeof v === "boolean" ? v : null;
7582
- const num = (v) => typeof v === "number" ? v : null;
7583
- const milestones = [
7584
- { name: "firstResponse", met: bool(row.isFirstResponseMet), elapsedHours: num(row.firstResponseElapsedHours) },
7585
- { name: "resolution", met: bool(row.isResolutionMet), elapsedHours: num(row.resolutionElapsedHours) },
7586
- { name: "resolutionPlan", met: bool(row.isResolutionPlanMet), elapsedHours: num(row.resolutionPlanElapsedHours) }
7587
- ];
7588
- return { id: Number(row.id), name: row.serviceLevelAgreementName ?? null, milestones };
7589
- });
7590
- const healthy = slas.every((sla) => sla.milestones.every((m) => m.met === true));
7591
- return { ticketId, slaCount: slas.length, healthy, slas, truncated: rows.length >= SLA_PAGE };
7592
7893
  }
7593
- async function summary(_resource, id, opts = {}, http) {
7594
- const limit = opts.summaryTextLimit === void 0 ? 500 : Math.trunc(opts.summaryTextLimit);
7595
- if (!Number.isInteger(limit) || limit < 1) {
7596
- throw new AutotaskConfigError(`summaryTextLimit must be an integer >= 1 (got ${String(opts.summaryTextLimit)})`);
7894
+ function resolveMergeData(ticketId, targetStatus, previousStatus, current, fields, mapEntry) {
7895
+ const mergeData = { status: targetStatus };
7896
+ for (const [k, v] of Object.entries(fields ?? {})) {
7897
+ if (v !== void 0) mergeData[k] = v;
7597
7898
  }
7598
- const ticketId = parseIdentifier(id, "id");
7599
- const ticket = await fetchTicket(http, ticketId, "tickets.summary");
7600
- const desc = truncate(ticket.description, limit);
7601
- const now = opts.now ?? /* @__PURE__ */ new Date();
7602
- const ageDays = typeof ticket.createDate === "string" && ticket.createDate !== "" ? Math.max(0, Math.floor((now.getTime() - Date.parse(ticket.createDate)) / MS)) : null;
7603
- const pick = (v) => typeof v === "number" ? v : null;
7604
- const str = (v) => typeof v === "string" && v !== "" ? v : null;
7605
- return {
7606
- ticketId,
7607
- ticketNumber: str(ticket.ticketNumber),
7608
- title: str(ticket.title),
7609
- description: desc.value,
7610
- descriptionLength: desc.length,
7611
- descriptionTruncated: desc.truncated,
7612
- status: pick(ticket.status),
7613
- priority: pick(ticket.priority),
7614
- companyID: pick(ticket.companyID),
7615
- contactID: pick(ticket.contactID),
7616
- assignedResourceID: pick(ticket.assignedResourceID),
7617
- projectID: pick(ticket.projectID),
7618
- createDate: str(ticket.createDate),
7619
- dueDateTime: str(ticket.dueDateTime),
7620
- ageDays
7621
- };
7622
- }
7623
-
7624
- // src/resources/special/tickets/timeline.ts
7625
- function validateTimelineLimits(opts) {
7626
- const limit = opts.limit === void 0 ? 100 : Math.trunc(opts.limit);
7627
- if (!Number.isInteger(limit) || limit < 1) throw new AutotaskConfigError(`limit must be an integer >= 1 (got ${String(opts.limit)})`);
7628
- const bounded = Math.min(limit, 500);
7629
- const textLimit = opts.textLimit === void 0 ? 500 : Math.trunc(opts.textLimit);
7630
- if (!Number.isInteger(textLimit) || textLimit < 1) {
7631
- throw new AutotaskConfigError(`textLimit must be an integer >= 1 (got ${String(opts.textLimit)})`);
7899
+ const filled = [];
7900
+ if (mapEntry !== void 0) {
7901
+ for (const field of mapEntry.requiredFields) {
7902
+ if (mergeData[field] !== void 0) continue;
7903
+ if (current[field] !== null && current[field] !== void 0) continue;
7904
+ const safe = mapEntry.safeDefaults?.[field];
7905
+ if (safe === void 0) {
7906
+ throw new StatusFieldsRequiredError(
7907
+ `changeStatus: status ${targetStatus} requires the field '${field}' and the live-discovered map has no proven-safe default for it \u2014 the op is safe-without-the-map (never a blind full update): supply the value in fields and retry`,
7908
+ { vendorFields: [field], currentStatus: previousStatus, targetStatus, currentValues: { [field]: current[field] ?? null } },
7909
+ { operation: OP_STATUS, resourceIds: [ticketId], details: { idRefs: [{ entity: "ticket", id: ticketId }] } }
7910
+ );
7911
+ }
7912
+ mergeData[field] = safe;
7913
+ filled.push(field);
7914
+ }
7632
7915
  }
7633
- return { bounded, textLimit };
7916
+ return { mergeData, filled };
7634
7917
  }
7635
- function noteEvent(n, textLimit) {
7636
- const text = truncate(typeof n.description === "string" ? n.description : null, textLimit);
7637
- return {
7638
- kind: "note",
7639
- id: Number(n.id),
7640
- date: typeof n.createDateTime === "string" ? n.createDateTime : null,
7641
- text: text.value || null,
7642
- title: typeof n.title === "string" ? n.title : null,
7643
- hours: null,
7644
- action: null,
7645
- textTruncated: text.truncated
7646
- };
7647
- }
7648
- function timeEntryEvent(t) {
7649
- return {
7650
- kind: "timeEntry",
7651
- id: Number(t.id),
7652
- date: typeof t.dateWorked === "string" ? t.dateWorked : null,
7653
- text: null,
7654
- title: null,
7655
- hours: typeof t.hoursWorked === "number" ? t.hoursWorked : null,
7656
- action: null,
7657
- textTruncated: false
7658
- };
7659
- }
7660
- function historyEvent(h, textLimit) {
7661
- const text = truncate(typeof h.detail === "string" ? h.detail : null, textLimit);
7662
- return {
7663
- kind: "history",
7664
- id: Number(h.id),
7665
- date: typeof h.date === "string" ? h.date : null,
7666
- text: text.value || null,
7667
- title: null,
7668
- hours: null,
7669
- action: typeof h.action === "string" ? h.action : null,
7670
- textTruncated: text.truncated
7671
- };
7672
- }
7673
- function compareEventDates(a, b) {
7674
- const da = a.date ?? "";
7675
- const db = b.date ?? "";
7676
- if (da === "" && db === "") return 0;
7677
- if (da === "") return 1;
7678
- if (db === "") return -1;
7679
- return da < db ? -1 : da > db ? 1 : 0;
7680
- }
7681
- async function timeline(_resource, id, opts = {}, http) {
7682
- const { bounded, textLimit } = validateTimelineLimits(opts);
7683
- const ticketId = parseIdentifier(id, "id");
7684
- await fetchTicket(http, ticketId, "tickets.timeline");
7685
- const dateFilters = (field) => {
7686
- const f = [{ field: "ticketID", op: "eq", value: ticketId }];
7687
- if (opts.since !== void 0) f.push({ field, op: "gt", value: opts.since });
7688
- if (opts.until !== void 0) f.push({ field, op: "lt", value: opts.until });
7689
- return f;
7690
- };
7691
- const events = [];
7692
- const cappedSources = [];
7693
- const notes = await ticketChildRead(http, { globalPath: "/V1.0/TicketNotes/query", resourceKey: "ticket_notes", filter: dateFilters("createDateTime"), limit: bounded, operation: "tickets.timeline" }, ticketId);
7694
- if (notes.capped) cappedSources.push("notes");
7695
- for (const n of notes.items) events.push(noteEvent(n, textLimit));
7696
- const entries = await ticketChildRead(http, { globalPath: "/V1.0/TimeEntries/query", resourceKey: "time_entries", filter: dateFilters("dateWorked"), limit: bounded, operation: "tickets.timeline" }, ticketId);
7697
- if (entries.capped) cappedSources.push("timeEntries");
7698
- for (const t of entries.items) events.push(timeEntryEvent(t));
7699
- let historiesRead;
7700
- if (opts.includeHistories) {
7701
- historiesRead = await ticketChildRead(http, { globalPath: "/V1.0/TicketHistory/query", resourceKey: "ticket_history", filter: dateFilters("date"), limit: bounded, operation: "tickets.timeline" }, ticketId);
7702
- if (historiesRead.capped) cappedSources.push("histories");
7703
- for (const h of historiesRead.items) events.push(historyEvent(h, textLimit));
7704
- }
7705
- events.sort(compareEventDates);
7706
- const capped = events.slice(0, bounded);
7707
- const result = {
7708
- ticketId,
7709
- count: capped.length,
7710
- events: capped,
7711
- truncated: cappedSources.length > 0 || events.length > bounded,
7712
- cappedSources
7713
- };
7714
- if (notes.status !== "ok" || entries.status !== "ok" || historiesRead !== void 0 && historiesRead.status !== "ok") {
7715
- const routes = { notes: notes.status, timeEntries: entries.status };
7716
- if (historiesRead !== void 0) routes.histories = historiesRead.status;
7717
- result.sourceRoutes = routes;
7718
- }
7719
- return result;
7720
- }
7721
-
7722
- // src/resources/special/tickets/errors.ts
7723
- var QueueRequiredError = class extends AutotaskError {
7724
- constructor(message, options = {}) {
7725
- super(message, {
7726
- code: "QUEUE_REQUIRED",
7727
- category: "validation",
7728
- suggestedAction: "pass params.queueID (discover via autotask_describe(tickets.create) or a known queue id)",
7729
- ...options
7730
- });
7731
- this.name = "QueueRequiredError";
7732
- }
7733
- };
7734
- var TicketResourceInvalidError = class extends AutotaskError {
7735
- constructor(message, options = {}) {
7736
- super(message, { code: "RESOURCE_INVALID", category: "validation", ...options });
7737
- this.name = "TicketResourceInvalidError";
7738
- }
7739
- };
7740
- var TicketCreateValidationFailedError = class extends AutotaskError {
7741
- /** The vendor-named fields (empty when the 500 body named nothing parseable). */
7742
- vendorFields;
7743
- constructor(message, vendorFields, options = {}) {
7744
- super(message, { code: "VALIDATION_FAILED", category: "validation", ...options });
7745
- this.name = "TicketCreateValidationFailedError";
7746
- this.vendorFields = vendorFields;
7747
- }
7748
- };
7749
- var StatusUnknownError = class extends AutotaskError {
7750
- /** The live picklist size (the message names it; the values + labels are via operations.fieldCatalog with labels: true). */
7751
- picklistCount;
7752
- constructor(message, options = {}) {
7753
- super(message, {
7754
- code: "STATUS_UNKNOWN",
7755
- category: "validation",
7756
- suggestedAction: `Pass one of the tenant's live status values (discover the current values + labels with operations.fieldCatalog({ resources: ["tickets"], fields: ["status"], labels: true })).`,
7757
- ...options
7758
- });
7759
- this.name = "StatusUnknownError";
7760
- this.picklistCount = options.picklistCount ?? 0;
7761
- }
7762
- };
7763
- var StatusFieldsRequiredError = class extends AutotaskError {
7764
- vendorFields;
7765
- currentStatus;
7766
- targetStatus;
7767
- currentValues;
7768
- constructor(message, details, options = {}) {
7769
- super(message, { code: "STATUS_FIELDS_REQUIRED", category: "validation", suggestedAction: "Pass the required field value(s) in the `fields` argument and retry (the node never fills a field without a proven-safe default).", ...options });
7770
- this.name = "StatusFieldsRequiredError";
7771
- this.vendorFields = details.vendorFields;
7772
- this.currentStatus = details.currentStatus;
7773
- this.targetStatus = details.targetStatus;
7774
- this.currentValues = details.currentValues;
7775
- }
7776
- };
7777
-
7778
- // src/resources/special/tickets/writeSupport.ts
7779
- var OP_OPEN = "tickets.openWithNoteAndAssign";
7780
- var OP_STATUS = "tickets.changeStatus";
7781
- var MAX_DISCOVERY_MERGES = 3;
7782
- function isPositiveInt(v) {
7783
- return typeof v === "number" && Number.isInteger(v) && v > 0;
7784
- }
7785
- function namedVendorFields(err) {
7786
- if (!isAutotaskError(err) || err.vendorError === void 0) return [];
7787
- const entries = parseVendorErrors(err.vendorError) ?? [];
7788
- const out = /* @__PURE__ */ new Set();
7789
- for (const e of entries) if (e.field !== void 0) out.add(e.field);
7790
- return [...out];
7791
- }
7792
- function vendorDetailOf(err) {
7793
- return isAutotaskError(err) ? formatVendorDetail(err.vendorError) : void 0;
7794
- }
7795
- async function fetchItem(http, path, operation, readOpts) {
7796
- const res = await http.request({ method: "GET", path, operation, maxRetries: readOpts?.maxRetries });
7797
- return unwrapItemOrNull(res);
7798
- }
7799
- async function fetchItemUnredacted(http, path, operation, readOpts) {
7800
- const res = await http.request({ method: "GET", path, operation, skipResponseRedaction: true, maxRetries: readOpts?.maxRetries });
7801
- return unwrapItemOrNull(res);
7802
- }
7803
- function unwrapItemOrNull(res) {
7804
- const body = res;
7805
- if (body === null || !("item" in body) || body.item === null || body.item === void 0) return null;
7806
- return body.item;
7807
- }
7808
- function operationId(res) {
7809
- const body = res;
7810
- if (body === null) return void 0;
7811
- return typeof body.itemId === "number" ? body.itemId : typeof body.id === "number" ? body.id : void 0;
7812
- }
7813
- function discoveryMerge(err, body, defaults) {
7814
- if (!isAutotaskError(err) || err.vendorError === void 0) return null;
7815
- const entries = parseVendorErrors(err.vendorError);
7816
- if (entries === void 0) return null;
7817
- for (const entry of entries) {
7818
- const field = entry.field;
7819
- if (field === void 0 || body[field] !== void 0) continue;
7820
- if (defaults[field] === void 0) continue;
7821
- return { field, value: defaults[field] };
7822
- }
7823
- return null;
7824
- }
7825
- function stepErrorText(code, err) {
7826
- const detail = vendorDetailOf(err);
7827
- const base = isAutotaskError(err) ? err.message : String(err);
7828
- return detail ? `${code}: ${detail}` : `${code}: ${base}`;
7829
- }
7830
-
7831
- // src/resources/special/tickets/openWithNoteAndAssign.ts
7832
- var DEFAULT_TICKET_PRIORITY = 3;
7833
- var DEFAULT_TICKET_STATUS = 1;
7834
- var DEFAULT_NOTE_TYPE = 1;
7835
- var DEFAULT_PUBLISH = 1;
7836
- var PUBLISH_VALUES = /* @__PURE__ */ new Set([1, 2, 4]);
7837
- var SAFE_TICKET_CREATE_DEFAULTS = {};
7838
- function validateOpenInput(params, note, assign) {
7839
- const companyID = parseIdentifier(params?.companyID, "params.companyID");
7840
- const title = params?.title;
7841
- if (typeof title !== "string" || title.trim() === "") {
7842
- throw new AutotaskConfigError("openWithNoteAndAssign: params.title is required (a non-empty string)", { operation: OP_OPEN });
7843
- }
7844
- const priority = params.priority ?? DEFAULT_TICKET_PRIORITY;
7845
- const status = params.status ?? DEFAULT_TICKET_STATUS;
7846
- if (!isPositiveInt(priority)) {
7847
- throw new AutotaskConfigError(`openWithNoteAndAssign: params.priority must be a positive integer (got ${String(params.priority)})`, { operation: OP_OPEN });
7848
- }
7849
- if (!isPositiveInt(status)) {
7850
- throw new AutotaskConfigError(`openWithNoteAndAssign: params.status must be a positive integer (got ${String(params.status)})`, { operation: OP_OPEN });
7851
- }
7852
- if (params.queueID !== void 0 && !isPositiveInt(params.queueID)) {
7853
- throw new AutotaskConfigError(`openWithNoteAndAssign: params.queueID must be a positive integer (got ${String(params.queueID)})`, { operation: OP_OPEN });
7854
- }
7855
- if (note !== void 0) {
7856
- if (typeof note.description !== "string" || note.description.trim() === "") {
7857
- throw new AutotaskConfigError("openWithNoteAndAssign: note.description is required (a non-empty string)", { operation: OP_OPEN });
7858
- }
7859
- if (typeof note.title !== "string" || note.title.trim() === "") {
7860
- throw new AutotaskConfigError("openWithNoteAndAssign: note.title is required (a non-empty string) \u2014 the ww29 scoped note create 500s without it (S12-A live-verified: Missing Required Field: title; the S11-B proven note body is { title, description, noteType: 1, publish: 1 })", { operation: OP_OPEN });
7861
- }
7862
- if (note.noteType !== void 0 && !isPositiveInt(note.noteType)) {
7863
- throw new AutotaskConfigError(`openWithNoteAndAssign: note.noteType must be a positive integer (got ${String(note.noteType)})`, { operation: OP_OPEN });
7864
- }
7865
- if (note.publish !== void 0 && !PUBLISH_VALUES.has(note.publish)) {
7866
- throw new AutotaskConfigError(`openWithNoteAndAssign: note.publish must be an integer picklist value 1/2/4 (got ${String(note.publish)} \u2014 NOT a boolean)`, { operation: OP_OPEN });
7867
- }
7868
- }
7869
- const assignee = assign === void 0 ? void 0 : parseIdentifier(assign.resourceID, "assign.resourceID");
7870
- if (assign !== void 0 && assignee !== void 0 && (typeof assignee === "number" ? assignee < 1 : false)) {
7871
- throw new AutotaskConfigError(`openWithNoteAndAssign: assign.resourceID must be a positive integer (got ${String(assignee)})`, { operation: OP_OPEN });
7872
- }
7873
- const roleID = assign?.roleID;
7874
- if (assign !== void 0 && !isPositiveInt(roleID)) {
7875
- throw new AutotaskConfigError("openWithNoteAndAssign: assign.roleID is required (a positive integer) \u2014 the ww29 vendor requires BOTH assignedResourceID and assignedResourceRoleID when assigning a resource (S12-A live-verified: Data violation: When assigning a Resource, you must assign both a assignedResourceID and assignedResourceRoleID)", { operation: OP_OPEN });
7876
- }
7877
- return { companyID, title, priority, status, assignee, roleID };
7878
- }
7879
- async function precheckAssignee(http, assignee, opts) {
7880
- const rid = assignee;
7881
- const rec = await fetchItem(http, `/V1.0/Resources/${rid}`, OP_OPEN, { maxRetries: opts.maxRetries });
7882
- if (rec === null) {
7883
- throw new TicketResourceInvalidError(
7884
- `openWithNoteAndAssign: assignee resource ${assignee} was not found (resources.get returned item: null \u2014 the resources table is blind to the API user; get is the authority)`,
7885
- { resourceIds: [rid], details: { idRefs: [{ entity: "resource", id: rid }] }, operation: OP_OPEN, category: "not_found", suggestedAction: "Resolve the resource id (a ticket assignedResourceID via tickets.fullContext) and retry." }
7886
- );
7887
- }
7888
- if (rec.isActive !== true) {
7889
- throw new TicketResourceInvalidError(
7890
- `openWithNoteAndAssign: assignee resource ${assignee} is not active (isActive !== true) \u2014 the vendor would reject the assignment`,
7891
- { resourceIds: [rid], details: { idRefs: [{ entity: "resource", id: rid }] }, operation: OP_OPEN, suggestedAction: "Activate the resource or assign an active one." }
7892
- );
7893
- }
7894
- }
7895
- function openDryRun(http, v, params, assign, body, noteBody) {
7896
- const { companyID, assignee, roleID } = v;
7918
+ function mergeDryRun(http, ticketId, targetStatus, guardDegraded, filled, mapEntry, mergeData) {
7897
7919
  const warnings = [];
7898
- if (params.queueID === void 0) {
7899
- warnings.push("queueID is omitted \u2014 the node NEVER guesses a queue: if the vendor 500 names it, the apply throws the typed QUEUE_REQUIRED (pass params.queueID \u2014 discover via autotask_describe(tickets.create)).");
7920
+ if (guardDegraded) warnings.push(GUARD_DEGRADED_WARNING);
7921
+ if (filled.length > 0) {
7922
+ warnings.push(`the node will fill the map-required field(s) ${filled.join(", ")} with the proven-safe defaults (the live-discovered map); the caller value always wins.`);
7900
7923
  }
7901
- if (assign !== void 0) {
7902
- warnings.push(`the assignment is the v0.6.0 MERGE update (unredacted GET + collection PUT, 2 wire) setting assignedResourceID ${String(assignee)} + assignedResourceRoleID ${String(roleID)} (the vendor requires both \u2014 S12-A live-verified); a failure lands in steps[] as ASSIGN_FAILED (the applied ticketId is still returned).`);
7924
+ if (mapEntry === void 0) {
7925
+ warnings.push(`status ${targetStatus} has no entry in the live-discovered required-field map yet \u2014 if the vendor 500 names a required field without a safe default, the apply throws the typed STATUS_FIELDS_REQUIRED (the caller supplies the value; never a blind retry).`);
7903
7926
  }
7904
7927
  return buildDryRunResult(
7905
7928
  {
7906
- operation: OP_OPEN,
7907
- method: "POST",
7929
+ operation: OP_STATUS,
7930
+ method: "PUT",
7908
7931
  path: "/V1.0/Tickets",
7909
- ids: [companyID],
7932
+ ids: [ticketId],
7910
7933
  checks: [
7911
- { name: "input-validation", ok: true },
7912
- { name: "assignee-active", ok: true }
7934
+ { name: "status-known", ok: true },
7935
+ { name: "merge-read", ok: true },
7936
+ { name: "fields-resolved", ok: true }
7913
7937
  ],
7914
- impact: {
7915
- affected: 1 + (noteBody !== null ? 1 : 0) + (assign !== void 0 ? 1 : 0),
7916
- scope: "ticket (+ scoped note, + assignment merge)",
7917
- reversible: false
7918
- },
7938
+ impact: { affected: 1, scope: "single-record", reversible: true },
7919
7939
  warnings,
7920
- data: {
7921
- ticket: body,
7922
- note: noteBody,
7923
- assign: assign !== void 0 ? { method: "GET+PUT", path: `/V1.0/Tickets/<newId>`, body: { assignedResourceID: assignee, assignedResourceRoleID: roleID } } : null
7924
- }
7940
+ data: mergeData
7925
7941
  },
7926
7942
  { redactPersonalData: http.redactPersonalData }
7927
7943
  );
7928
7944
  }
7929
- async function createTicketWithDiscovery(resource, params, companyID, body, steps) {
7945
+ async function putStatusWithDiscovery(http, ticketId, targetStatus, previousStatus, current, mergeData, mapEntry, filled, opts) {
7946
+ const safeDefaults = mapEntry?.safeDefaults ?? {};
7947
+ const body = mergePartialBody(current, mergeData);
7948
+ body.id = ticketId;
7930
7949
  let merges = 0;
7931
7950
  for (; ; ) {
7932
7951
  try {
7933
- const created = await resource.create(body, {});
7934
- steps.push({ name: "create", ok: true });
7935
- return created.id;
7952
+ await http.request({ method: "PUT", path: "/V1.0/Tickets", body, operation: OP_STATUS, maxRetries: opts.maxRetries });
7953
+ return merges;
7936
7954
  } catch (err) {
7937
7955
  const named = namedVendorFields(err);
7938
- if (params.queueID === void 0 && named.includes("queueID")) {
7939
- const detail = vendorDetailOf(err);
7940
- throw new QueueRequiredError(
7941
- `openWithNoteAndAssign: the vendor rejected the ticket create naming 'queueID' (the ww29 live-required set is { title, status, priority, companyID, queueID }) and the node never guesses a queue${detail ? ` \u2014 ${detail}` : ""}`,
7942
- {
7943
- operation: OP_OPEN,
7944
- httpStatus: isAutotaskError(err) ? err.httpStatus : void 0,
7945
- vendorError: isAutotaskError(err) ? err.vendorError : void 0
7946
- }
7947
- );
7948
- }
7949
- const merge = discoveryMerge(err, body, SAFE_TICKET_CREATE_DEFAULTS);
7956
+ const merge = discoveryMerge(err, body, safeDefaults);
7950
7957
  if (merge !== null && merges < MAX_DISCOVERY_MERGES) {
7951
7958
  body[merge.field] = merge.value;
7959
+ if (!filled.includes(merge.field)) filled.push(merge.field);
7952
7960
  merges += 1;
7953
7961
  continue;
7954
7962
  }
7955
- throw new TicketCreateValidationFailedError(
7956
- `openWithNoteAndAssign: the vendor rejected the ticket create${named.length > 0 ? ` naming field(s) ${named.join(", ")}` : ""} (no proven-safe default \u2014 the discovery loop has no fill for a ticket create)${merges > 0 ? ` after ${merges} discovery merge(s)` : ""}`,
7957
- named,
7958
- {
7959
- operation: OP_OPEN,
7960
- httpStatus: isAutotaskError(err) ? err.httpStatus : void 0,
7961
- vendorError: isAutotaskError(err) ? err.vendorError : void 0,
7962
- resourceIds: [companyID],
7963
- details: { idRefs: [{ entity: "company", id: companyID }] }
7964
- }
7965
- );
7963
+ if (named.length > 0 && named.every((f) => safeDefaults[f] === void 0)) {
7964
+ const currentValues = {};
7965
+ for (const f of named) currentValues[f] = current[f] ?? null;
7966
+ throw new StatusFieldsRequiredError(
7967
+ `changeStatus: the vendor rejected the status change ${previousStatus ?? "?"} \u2192 ${targetStatus} naming field(s) ${named.join(", ")} that have no proven-safe default \u2014 supply the value(s) in fields and retry (no blind retry)`,
7968
+ { vendorFields: named, currentStatus: previousStatus, targetStatus, currentValues },
7969
+ {
7970
+ operation: OP_STATUS,
7971
+ httpStatus: isAutotaskError(err) ? err.httpStatus : void 0,
7972
+ vendorError: isAutotaskError(err) ? err.vendorError : void 0,
7973
+ resourceIds: [ticketId],
7974
+ details: { idRefs: [{ entity: "ticket", id: ticketId }] }
7975
+ }
7976
+ );
7977
+ }
7978
+ throw err;
7966
7979
  }
7967
7980
  }
7968
7981
  }
7969
- async function createNoteStep(http, ticketId, noteBody, opts, steps) {
7970
- try {
7971
- const res = await http.request({ method: "POST", path: `/V1.0/Tickets/${ticketId}/Notes`, body: noteBody, operation: OP_OPEN, maxRetries: opts.maxRetries });
7972
- const noteId = operationId(res) ?? null;
7973
- steps.push({ name: "note", ok: true });
7974
- return noteId;
7975
- } catch (err) {
7976
- steps.push({ name: "note", ok: false, error: stepErrorText("NOTE_CREATE_FAILED", err) });
7977
- return null;
7978
- }
7979
- }
7980
- async function assignStep(http, ticketId, assignee, roleID, opts, steps) {
7981
- let assigned = false;
7982
- let ticketNumber = null;
7983
- try {
7984
- const current = await fetchItemUnredacted(http, `/V1.0/Tickets/${ticketId}`, OP_OPEN, { maxRetries: opts.maxRetries });
7985
- if (current === null) {
7986
- throw new NotFoundError(`openWithNoteAndAssign: ticket ${ticketId} vanished between the create and the assign merge read`, { operation: OP_OPEN, resourceIds: [ticketId], details: { idRefs: [{ entity: "ticket", id: ticketId }] } });
7987
- }
7988
- ticketNumber = typeof current.ticketNumber === "string" ? current.ticketNumber : null;
7989
- const merged = mergePartialBody(current, { id: ticketId, assignedResourceID: assignee, assignedResourceRoleID: roleID });
7990
- merged.id = ticketId;
7991
- await http.request({ method: "PUT", path: "/V1.0/Tickets", body: merged, operation: OP_OPEN, maxRetries: opts.maxRetries });
7992
- assigned = true;
7993
- steps.push({ name: "assign", ok: true });
7994
- } catch (err) {
7995
- steps.push({ name: "assign", ok: false, error: stepErrorText("ASSIGN_FAILED", err) });
7996
- }
7997
- return { assigned, ticketNumber };
7998
- }
7999
- async function openWithNoteAndAssign(resource, params, note, assign, opts, http) {
8000
- const validated = validateOpenInput(params, note, assign);
8001
- const { companyID, title, priority, status, assignee } = validated;
8002
- if (assign !== void 0 && assignee !== void 0) await precheckAssignee(http, assignee, opts);
8003
- const body = { title, status, priority, companyID };
8004
- if (params.queueID !== void 0) body.queueID = params.queueID;
8005
- const noteBody = note === void 0 ? null : {
8006
- title: note.title,
8007
- // REQUIRED by the vendor (S12-A live-verified: Missing Required Field: title)
8008
- description: note.description,
8009
- noteType: note.noteType ?? DEFAULT_NOTE_TYPE,
8010
- publish: note.publish ?? DEFAULT_PUBLISH
8011
- };
8012
- if (opts.dryRun === true) return openDryRun(http, validated, params, assign, body, noteBody);
8013
- const steps = [];
8014
- const ticketId = await createTicketWithDiscovery(resource, params, companyID, body, steps);
8015
- const noteId = noteBody !== null ? await createNoteStep(http, ticketId, noteBody, opts, steps) : null;
8016
- let assigned = false;
8017
- let ticketNumber = null;
8018
- if (assign !== void 0) ({ assigned, ticketNumber } = await assignStep(http, ticketId, assignee, validated.roleID, opts, steps));
8019
- return {
8020
- ticketId,
8021
- ticketNumber,
8022
- noteId,
8023
- assigned,
8024
- steps,
8025
- ...assign !== void 0 ? { blind: { resources: true, note: "the resources table is blind to this API user (count/query return 0) \u2014 the assignee was verified via resources.get (the authority)" } } : {}
8026
- };
8027
- }
8028
-
8029
- // src/resources/special/tickets/changeStatus.ts
8030
- var STATUS_REQUIRED_FIELDS = {
8031
- // <discovery output lands here — e.g. 8: { requiredFields: ['dueDateTime'], safeDefaults: { dueDateTime: '<now>' } }>
8032
- };
8033
- async function readLiveStatusSet(resource) {
8034
- const raw = await resource.getFieldInfo();
8035
- const row = raw.find((f) => f.name === "status");
8036
- const values = row !== void 0 && row.isPickList === true ? row.picklistValues : void 0;
8037
- const ids = /* @__PURE__ */ new Set();
8038
- if (Array.isArray(values)) {
8039
- for (const pv of values) {
8040
- const n = Number(pv.value);
8041
- if (Number.isFinite(n)) ids.add(n);
8042
- }
8043
- }
8044
- return ids;
8045
- }
8046
- var GUARD_DEGRADED_WARNING = "the tenant's live status picklist metadata was unavailable (the local guard is degraded for this call \u2014 the W-A4 wall or a metadata failure); the vendor's own validation is the final authority on the status value";
8047
- async function guardTargetStatus(resource, http, targetStatus) {
8048
- let liveStatus = null;
8049
- try {
8050
- const ids = await readLiveStatusSet(resource);
8051
- if (ids.size > 0) liveStatus = ids;
8052
- } catch (err) {
8053
- if (!isAutotaskError(err) || err.code === "ABORTED") throw err;
8054
- }
8055
- if (liveStatus !== null && !liveStatus.has(targetStatus)) {
8056
- http.fieldCache?.invalidate("tickets:");
8057
- let freshStatus = null;
8058
- let reReadFailed = false;
8059
- try {
8060
- const freshIds = await readLiveStatusSet(resource);
8061
- if (freshIds.size > 0) freshStatus = freshIds;
8062
- } catch (err) {
8063
- if (!isAutotaskError(err) || err.code === "ABORTED") throw err;
8064
- freshStatus = null;
8065
- reReadFailed = true;
8066
- }
8067
- if (freshStatus !== null && freshStatus.has(targetStatus)) {
8068
- liveStatus = freshStatus;
8069
- } else {
8070
- const verdict = freshStatus ?? liveStatus;
8071
- const staleNote = freshStatus === null ? `; the live re-read ${reReadFailed ? "failed" : "returned no values"}, so this picklist count is from the initial read (up to one field-cache TTL old)` : "";
8072
- throw new StatusUnknownError(
8073
- `changeStatus: targetStatus ${targetStatus} is not in the tenant's live status picklist (live: ${verdict.size} values \u2014 the entity metadata is the oracle; the values + labels are discoverable with operations.fieldCatalog({ resources: ["tickets"], fields: ["status"], labels: true })${staleNote})`,
8074
- { operation: OP_STATUS, picklistCount: verdict.size }
8075
- );
8076
- }
8077
- }
8078
- return { guardDegraded: liveStatus === null };
8079
- }
8080
- function noChangeDryRun(http, ticketId, targetStatus, guardDegraded) {
8081
- return buildDryRunResult(
8082
- {
8083
- operation: OP_STATUS,
8084
- method: "PUT",
8085
- path: "/V1.0/Tickets",
8086
- ids: [ticketId],
8087
- checks: [
8088
- { name: "status-known", ok: true },
8089
- { name: "merge-read", ok: true },
8090
- { name: "no-op", ok: true }
8091
- ],
8092
- impact: { affected: 0, scope: "single-record", reversible: true },
8093
- warnings: [
8094
- ...guardDegraded ? [GUARD_DEGRADED_WARNING] : [],
8095
- `NO_CHANGE: the ticket is already in the target status ${targetStatus} \u2014 the apply would be a zero-wire no-op.`
8096
- ],
8097
- data: { reason: "NO_CHANGE", status: targetStatus }
8098
- },
8099
- { redactPersonalData: http.redactPersonalData }
8100
- );
8101
- }
8102
- function resolveMergeData(ticketId, targetStatus, previousStatus, current, fields, mapEntry) {
8103
- const mergeData = { status: targetStatus };
8104
- for (const [k, v] of Object.entries(fields ?? {})) {
8105
- if (v !== void 0) mergeData[k] = v;
8106
- }
8107
- const filled = [];
8108
- if (mapEntry !== void 0) {
8109
- for (const field of mapEntry.requiredFields) {
8110
- if (mergeData[field] !== void 0) continue;
8111
- if (current[field] !== null && current[field] !== void 0) continue;
8112
- const safe = mapEntry.safeDefaults?.[field];
8113
- if (safe === void 0) {
8114
- throw new StatusFieldsRequiredError(
8115
- `changeStatus: status ${targetStatus} requires the field '${field}' and the live-discovered map has no proven-safe default for it \u2014 the op is safe-without-the-map (never a blind full update): supply the value in fields and retry`,
8116
- { vendorFields: [field], currentStatus: previousStatus, targetStatus, currentValues: { [field]: current[field] ?? null } },
8117
- { operation: OP_STATUS, resourceIds: [ticketId], details: { idRefs: [{ entity: "ticket", id: ticketId }] } }
8118
- );
8119
- }
8120
- mergeData[field] = safe;
8121
- filled.push(field);
8122
- }
8123
- }
8124
- return { mergeData, filled };
8125
- }
8126
- function mergeDryRun(http, ticketId, targetStatus, guardDegraded, filled, mapEntry, mergeData) {
8127
- const warnings = [];
8128
- if (guardDegraded) warnings.push(GUARD_DEGRADED_WARNING);
8129
- if (filled.length > 0) {
8130
- warnings.push(`the node will fill the map-required field(s) ${filled.join(", ")} with the proven-safe defaults (the live-discovered map); the caller value always wins.`);
8131
- }
8132
- if (mapEntry === void 0) {
8133
- warnings.push(`status ${targetStatus} has no entry in the live-discovered required-field map yet \u2014 if the vendor 500 names a required field without a safe default, the apply throws the typed STATUS_FIELDS_REQUIRED (the caller supplies the value; never a blind retry).`);
8134
- }
8135
- return buildDryRunResult(
8136
- {
8137
- operation: OP_STATUS,
8138
- method: "PUT",
8139
- path: "/V1.0/Tickets",
8140
- ids: [ticketId],
8141
- checks: [
8142
- { name: "status-known", ok: true },
8143
- { name: "merge-read", ok: true },
8144
- { name: "fields-resolved", ok: true }
8145
- ],
8146
- impact: { affected: 1, scope: "single-record", reversible: true },
8147
- warnings,
8148
- data: mergeData
8149
- },
8150
- { redactPersonalData: http.redactPersonalData }
8151
- );
8152
- }
8153
- async function putStatusWithDiscovery(http, ticketId, targetStatus, previousStatus, current, mergeData, mapEntry, filled, opts) {
8154
- const safeDefaults = mapEntry?.safeDefaults ?? {};
8155
- const body = mergePartialBody(current, mergeData);
8156
- body.id = ticketId;
8157
- let merges = 0;
8158
- for (; ; ) {
8159
- try {
8160
- await http.request({ method: "PUT", path: "/V1.0/Tickets", body, operation: OP_STATUS, maxRetries: opts.maxRetries });
8161
- return merges;
8162
- } catch (err) {
8163
- const named = namedVendorFields(err);
8164
- const merge = discoveryMerge(err, body, safeDefaults);
8165
- if (merge !== null && merges < MAX_DISCOVERY_MERGES) {
8166
- body[merge.field] = merge.value;
8167
- if (!filled.includes(merge.field)) filled.push(merge.field);
8168
- merges += 1;
8169
- continue;
8170
- }
8171
- if (named.length > 0 && named.every((f) => safeDefaults[f] === void 0)) {
8172
- const currentValues = {};
8173
- for (const f of named) currentValues[f] = current[f] ?? null;
8174
- throw new StatusFieldsRequiredError(
8175
- `changeStatus: the vendor rejected the status change ${previousStatus ?? "?"} \u2192 ${targetStatus} naming field(s) ${named.join(", ")} that have no proven-safe default \u2014 supply the value(s) in fields and retry (no blind retry)`,
8176
- { vendorFields: named, currentStatus: previousStatus, targetStatus, currentValues },
8177
- {
8178
- operation: OP_STATUS,
8179
- httpStatus: isAutotaskError(err) ? err.httpStatus : void 0,
8180
- vendorError: isAutotaskError(err) ? err.vendorError : void 0,
8181
- resourceIds: [ticketId],
8182
- details: { idRefs: [{ entity: "ticket", id: ticketId }] }
8183
- }
8184
- );
8185
- }
8186
- throw err;
8187
- }
8188
- }
8189
- }
8190
- async function verifyStatusApplied(http, ticketId, targetStatus, opts) {
8191
- const verify = await fetchItem(http, `/V1.0/Tickets/${ticketId}`, OP_STATUS, { maxRetries: opts.maxRetries });
8192
- if (verify === null) {
8193
- throw new AutotaskError(`changeStatus: the post-PUT read-back found no ticket ${ticketId} (the PUT 200ed but the record is gone)`, {
8194
- code: "INVALID_RESPONSE",
8195
- category: "server",
8196
- operation: OP_STATUS,
8197
- resourceIds: [ticketId],
8198
- details: { idRefs: [{ entity: "ticket", id: ticketId }] }
8199
- });
7982
+ async function verifyStatusApplied(http, ticketId, targetStatus, opts) {
7983
+ const verify = await fetchItem(http, `/V1.0/Tickets/${ticketId}`, OP_STATUS, { maxRetries: opts.maxRetries });
7984
+ if (verify === null) {
7985
+ throw new AutotaskError(`changeStatus: the post-PUT read-back found no ticket ${ticketId} (the PUT 200ed but the record is gone)`, {
7986
+ code: "INVALID_RESPONSE",
7987
+ category: "server",
7988
+ operation: OP_STATUS,
7989
+ resourceIds: [ticketId],
7990
+ details: { idRefs: [{ entity: "ticket", id: ticketId }] }
7991
+ });
8200
7992
  }
8201
7993
  const finalStatus = typeof verify.status === "number" ? verify.status : null;
8202
7994
  if (finalStatus !== targetStatus) {
@@ -8214,7 +8006,8 @@ async function verifyStatusApplied(http, ticketId, targetStatus, opts) {
8214
8006
  );
8215
8007
  }
8216
8008
  }
8217
- async function changeStatus(resource, id, targetStatus, fields, opts, http) {
8009
+ async function changeStatus(resource, id, targetStatus, fields, opts) {
8010
+ const http = resource.http;
8218
8011
  const ticketId = parseIdentifier(id, "id");
8219
8012
  if (!isPositiveInt(targetStatus)) {
8220
8013
  throw new AutotaskConfigError(`changeStatus: targetStatus must be a positive integer (got ${String(targetStatus)})`, { operation: OP_STATUS });
@@ -8240,199 +8033,78 @@ async function changeStatus(resource, id, targetStatus, fields, opts, http) {
8240
8033
  return { ticketId, previousStatus, status: targetStatus, changed: true, filledFields: filled, discoveryMerges: merges };
8241
8034
  }
8242
8035
 
8243
- // src/resources/special/tickets/fullContext.ts
8244
- var DEFAULT_PART_LIMIT = 50;
8245
- function partLimit(value, name) {
8246
- const n = value === void 0 ? DEFAULT_PART_LIMIT : Math.trunc(value);
8247
- if (!Number.isInteger(n) || n < 1) {
8248
- throw new AutotaskConfigError(`fullContext: ${name} must be an integer >= 1 (got ${String(value)})`);
8036
+ // src/resources/special/tickets/forResource.ts
8037
+ var OP4 = "tickets.forResource";
8038
+ var TICKET_CHUNK = 100;
8039
+ var LIMIT_DEFAULT = 100;
8040
+ var LIMIT_MAX = 500;
8041
+ var LINK_READ_CAP = 500;
8042
+ var COUNT_LINK_PAGES = 20;
8043
+ var TICKET_FIELDS = ["id", "ticketNumber", "title", "status", "assignedResourceID", "companyID"];
8044
+ function positiveId(value, name) {
8045
+ const n = typeof value === "string" && /^\d+$/.test(value.trim()) ? Number(value) : value;
8046
+ if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) {
8047
+ throw new AutotaskConfigError(`${OP4}: ${name} must be a positive integer id (got ${String(value)})`, { operation: OP4 });
8249
8048
  }
8250
8049
  return n;
8251
8050
  }
8252
- function servedTextRecord(row, textField, limit) {
8253
- const text = row[textField];
8254
- if (typeof text !== "string" || text.length <= limit) return { ...row };
8255
- const out = { ...row };
8256
- out[`${textField}Truncated`] = true;
8257
- out[textField] = `${text.slice(0, limit)}\u2026`;
8258
- return out;
8051
+ async function ticketPage(http, filter, maxRecords, fields) {
8052
+ const res = await http.request({
8053
+ method: "POST",
8054
+ idempotent: true,
8055
+ path: "/V1.0/Tickets/query",
8056
+ body: { MaxRecords: maxRecords, filter, IncludeFields: fields },
8057
+ operation: OP4
8058
+ });
8059
+ const page = unwrapItems(res, OP4);
8060
+ return { rows: page.items, more: page.pageDetails.nextPageUrl !== null };
8259
8061
  }
8260
- async function addCompositeNames(http, ctx, entriesPart, result, opts) {
8261
- const builder = createNamePlan(TICKET_LABEL_FIELDS);
8262
- const t = ctx.ticket;
8263
- builder.addResource(t["creatorResourceID"]);
8264
- builder.addFk("contract", t["contractID"]);
8265
- builder.addFk("billingCode", t["billingCodeID"]);
8266
- builder.addFk("role", t["assignedResourceRoleID"]);
8267
- const ticketLobPointer = vendorId(t["organizationalLevelAssociationID"]);
8268
- if (ticketLobPointer !== void 0) {
8269
- builder.addLineOfBusiness(ticketLobPointer, { source: "ticket" });
8270
- } else {
8271
- builder.addLineOfBusinessFromContract(t["contractID"]);
8062
+ async function readLinkIds(http, resourceID, maxPages = 1) {
8063
+ const ids = /* @__PURE__ */ new Set();
8064
+ let cursor = null;
8065
+ for (let pages = 0; pages < maxPages; pages++) {
8066
+ const res = await http.request({
8067
+ method: "POST",
8068
+ idempotent: true,
8069
+ path: cursor === null ? "/V1.0/TicketSecondaryResources/query" : cursorToPath(http.baseUrl, cursor),
8070
+ body: { MaxRecords: LINK_READ_CAP, filter: [{ field: "resourceID", op: "eq", value: resourceID }], IncludeFields: ["ticketID"] },
8071
+ operation: OP4
8072
+ });
8073
+ const page = unwrapItems(res, OP4);
8074
+ for (const row of page.items) if (typeof row.ticketID === "number") ids.add(row.ticketID);
8075
+ cursor = page.pageDetails.nextPageUrl;
8076
+ if (cursor === null || page.items.length === 0) return { ids: [...ids], capped: false };
8272
8077
  }
8273
- for (const e of entriesPart.items) {
8274
- builder.addResource(e["resourceID"]);
8275
- builder.addFk("role", e["roleID"]);
8276
- builder.addFk("billingCode", e["billingCodeID"]);
8277
- builder.addFk("contract", e["contractID"]);
8278
- }
8279
- const names = await resolveCompositeNames(http, builder.plan(), { maxNameReads: opts.maxNameReads });
8280
- const ticketRow = { ...ctx.ticket };
8281
- addNameSibling(ticketRow, "creatorResourceID", (v) => names.resourceName(v));
8282
- addNameSibling(ticketRow, "contractID", (v) => names.recordName("contract", v));
8283
- addNameSibling(ticketRow, "billingCodeID", (v) => names.recordName("billingCode", v));
8284
- addNameSibling(ticketRow, "assignedResourceRoleID", (v) => names.recordName("role", v));
8285
- for (const f of TICKET_LABEL_FIELDS) addLabelSibling(ticketRow, f, names);
8286
- result.ticket = ticketRow;
8287
- result.timeEntries.items = entriesPart.items.map((e) => {
8288
- const row = { ...e };
8289
- addNameSibling(row, "resourceID", (v) => names.resourceName(v));
8290
- addNameSibling(row, "roleID", (v) => names.recordName("role", v));
8291
- addNameSibling(row, "billingCodeID", (v) => names.recordName("billingCode", v));
8292
- addNameSibling(row, "contractID", (v) => names.recordName("contract", v));
8293
- return row;
8294
- });
8295
- result.names = names.section;
8296
- result.lineOfBusiness = names.lineOfBusiness ?? {
8297
- association: null,
8298
- organizationalLevel2: null,
8299
- organizationalLevel1: null,
8300
- markers: { association: "missing", organizationalLevel2: "missing", organizationalLevel1: "missing" }
8301
- };
8302
- result.truncated = result.truncated || names.section.truncated;
8303
- }
8304
- async function fullContext(resource, id, opts = {}, http) {
8305
- const noteLimit = partLimit(opts.noteLimit, "noteLimit");
8306
- const timeEntryLimit = partLimit(opts.timeEntryLimit, "timeEntryLimit");
8307
- const textLimit = partLimit(opts.textLimit, "textLimit");
8308
- const ticketId = parseIdentifier(id, "id");
8309
- const ctx = await resource.getContext(ticketId);
8310
- const op = "tickets.fullContext";
8311
- const ticketFilter = [{ field: "ticketID", op: "eq", value: ticketId }];
8312
- const notes = await ticketChildRead(http, { globalPath: "/V1.0/TicketNotes/query", resourceKey: "ticket_notes", filter: ticketFilter, limit: noteLimit, operation: op }, ticketId);
8313
- const entries = await ticketChildRead(http, { globalPath: "/V1.0/TimeEntries/query", resourceKey: "time_entries", filter: ticketFilter, limit: timeEntryLimit, operation: op }, ticketId);
8314
- const notesRows = notes.items.map((n) => servedTextRecord(n, "description", textLimit));
8315
- const notesPart = { items: notesRows, capped: notes.capped };
8316
- applyRouteStatus(notesPart, notes);
8317
- const entriesPart = { items: entries.items, capped: entries.capped };
8318
- applyRouteStatus(entriesPart, entries);
8319
- const notesTextTruncated = notesRows.some((n) => n.descriptionTruncated === true);
8320
- let attachments = { ids: [], names: [], capped: false };
8321
- let attachmentsStatus = "ok";
8322
- if (opts.includeAttachments !== false) {
8323
- const read = await ticketChildRead(http, { globalPath: "/V1.0/TicketAttachments/query", resourceKey: "ticket_attachments", filter: ticketFilter, limit: DEFAULT_PART_LIMIT, operation: op }, ticketId);
8324
- attachmentsStatus = read.status;
8325
- attachments = {
8326
- ids: read.items.map((r) => Number(r.id)),
8327
- names: read.items.map((r) => typeof r.title === "string" ? r.title : null),
8328
- capped: read.capped
8329
- };
8330
- applyRouteStatus(attachments, read);
8331
- }
8332
- const result = {
8333
- ticket: ctx.ticket,
8334
- company: ctx.company,
8335
- contact: ctx.contact,
8336
- assignedResource: ctx.resource,
8337
- project: ctx.project,
8338
- notes: notesPart,
8339
- timeEntries: entriesPart,
8340
- attachments,
8341
- truncated: notesPart.capped || entriesPart.capped || attachments.capped || notesTextTruncated,
8342
- parts: {
8343
- company: ctx.company !== null ? "ok" : "absent",
8344
- contact: ctx.contact !== null ? "ok" : "absent",
8345
- assignedResource: ctx.resource !== null ? "ok" : "absent",
8346
- project: ctx.project !== null ? "ok" : "absent",
8347
- notes: notes.status,
8348
- timeEntries: entries.status,
8349
- attachments: attachmentsStatus
8350
- }
8351
- };
8352
- if (opts.includeHistories === true) {
8353
- const historiesRead = await ticketChildRead(http, { globalPath: "/V1.0/TicketHistory/query", resourceKey: "ticket_history", filter: ticketFilter, limit: DEFAULT_PART_LIMIT, operation: op }, ticketId);
8354
- const historyRows = historiesRead.items.map((h) => servedTextRecord(h, "detail", textLimit));
8355
- const part = { items: historyRows, capped: historiesRead.capped };
8356
- applyRouteStatus(part, historiesRead);
8357
- result.histories = part;
8358
- result.parts.histories = historiesRead.status;
8359
- result.truncated = result.truncated || part.capped || historyRows.some((h) => h.detailTruncated === true);
8360
- }
8361
- if (opts.names === true) await addCompositeNames(http, ctx, entriesPart, result, opts);
8362
- return result;
8363
- }
8364
-
8365
- // src/resources/special/tickets/forResource.ts
8366
- var OP2 = "tickets.forResource";
8367
- var TICKET_CHUNK = 100;
8368
- var LIMIT_DEFAULT = 100;
8369
- var LIMIT_MAX = 500;
8370
- var LINK_READ_CAP = 500;
8371
- var COUNT_LINK_PAGES = 20;
8372
- var TICKET_FIELDS = ["id", "ticketNumber", "title", "status", "assignedResourceID", "companyID"];
8373
- function positiveId(value, name) {
8374
- const n = typeof value === "string" && /^\d+$/.test(value.trim()) ? Number(value) : value;
8375
- if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) {
8376
- throw new AutotaskConfigError(`${OP2}: ${name} must be a positive integer id (got ${String(value)})`, { operation: OP2 });
8377
- }
8378
- return n;
8379
- }
8380
- async function ticketPage(http, filter, maxRecords, fields) {
8381
- const res = await http.request({
8382
- method: "POST",
8383
- idempotent: true,
8384
- path: "/V1.0/Tickets/query",
8385
- body: { MaxRecords: maxRecords, filter, IncludeFields: fields },
8386
- operation: OP2
8387
- });
8388
- const page = unwrapItems(res, OP2);
8389
- return { rows: page.items, more: page.pageDetails.nextPageUrl !== null };
8390
- }
8391
- async function readLinkIds(http, resourceID, maxPages = 1) {
8392
- const ids = /* @__PURE__ */ new Set();
8393
- let cursor = null;
8394
- for (let pages = 0; pages < maxPages; pages++) {
8395
- const res = await http.request({
8396
- method: "POST",
8397
- idempotent: true,
8398
- path: cursor === null ? "/V1.0/TicketSecondaryResources/query" : cursorToPath(http.baseUrl, cursor),
8399
- body: { MaxRecords: LINK_READ_CAP, filter: [{ field: "resourceID", op: "eq", value: resourceID }], IncludeFields: ["ticketID"] },
8400
- operation: OP2
8401
- });
8402
- const page = unwrapItems(res, OP2);
8403
- for (const row of page.items) if (typeof row.ticketID === "number") ids.add(row.ticketID);
8404
- cursor = page.pageDetails.nextPageUrl;
8405
- if (cursor === null || page.items.length === 0) return { ids: [...ids], capped: false };
8406
- }
8407
- return { ids: [...ids], capped: true };
8408
- }
8409
- async function forResource(_resource, resourceIDIn, http, opts = {}) {
8410
- if (opts === null || typeof opts !== "object") throw new AutotaskConfigError(`${OP2}: opts must be an object`, { operation: OP2 });
8411
- const resourceID = positiveId(resourceIDIn, "resourceID");
8412
- const scope = opts.scope ?? "both";
8413
- if (scope !== "primary" && scope !== "secondary" && scope !== "both") {
8414
- throw new AutotaskConfigError(`${OP2}: scope must be 'primary', 'secondary' or 'both' (got ${JSON.stringify(opts.scope)})`, { operation: OP2 });
8078
+ return { ids: [...ids], capped: true };
8079
+ }
8080
+ async function forResource(_resource, resourceIDIn, opts = {}) {
8081
+ const http = _resource.http;
8082
+ if (opts === null || typeof opts !== "object") throw new AutotaskConfigError(`${OP4}: opts must be an object`, { operation: OP4 });
8083
+ const resourceID = positiveId(resourceIDIn, "resourceID");
8084
+ const scope = opts.scope ?? "both";
8085
+ if (scope !== "primary" && scope !== "secondary" && scope !== "both") {
8086
+ throw new AutotaskConfigError(`${OP4}: scope must be 'primary', 'secondary' or 'both' (got ${JSON.stringify(opts.scope)})`, { operation: OP4 });
8415
8087
  }
8416
8088
  if (opts.openOnly !== void 0 && typeof opts.openOnly !== "boolean") {
8417
- throw new AutotaskConfigError(`${OP2}: openOnly must be a boolean`, { operation: OP2 });
8089
+ throw new AutotaskConfigError(`${OP4}: openOnly must be a boolean`, { operation: OP4 });
8418
8090
  }
8419
8091
  const limit = opts.limit === void 0 ? LIMIT_DEFAULT : opts.limit;
8420
8092
  if (opts.countOnly !== true && (!Number.isInteger(limit) || limit < 1 || limit > LIMIT_MAX)) {
8421
- throw new AutotaskConfigError(`${OP2}: limit must be an integer 1-${LIMIT_MAX} (got ${String(opts.limit)})`, { operation: OP2 });
8093
+ throw new AutotaskConfigError(`${OP4}: limit must be an integer 1-${LIMIT_MAX} (got ${String(opts.limit)})`, { operation: OP4 });
8422
8094
  }
8423
8095
  const includeFields = opts.includeFields ?? [];
8424
8096
  if (!Array.isArray(includeFields) || includeFields.some((f) => typeof f !== "string" || f.length === 0)) {
8425
- throw new AutotaskConfigError(`${OP2}: includeFields must be a list of field names`, { operation: OP2 });
8097
+ throw new AutotaskConfigError(`${OP4}: includeFields must be a list of field names`, { operation: OP4 });
8426
8098
  }
8427
8099
  const openOnly = opts.openOnly === true;
8428
8100
  const fields = [.../* @__PURE__ */ new Set([...TICKET_FIELDS, ...includeFields])];
8429
8101
  if (opts.countOnly !== void 0 && typeof opts.countOnly !== "boolean") {
8430
- throw new AutotaskConfigError(`${OP2}: countOnly must be a boolean`, { operation: OP2 });
8102
+ throw new AutotaskConfigError(`${OP4}: countOnly must be a boolean`, { operation: OP4 });
8431
8103
  }
8432
8104
  const closed = await resolveTicketStatusSemantics(
8433
8105
  http,
8434
8106
  { ...opts.excludeStatusIds === void 0 ? {} : { excludeStatusIds: opts.excludeStatusIds }, ...opts.allowAssumed === void 0 ? {} : { allowAssumed: opts.allowAssumed } },
8435
- OP2
8107
+ OP4
8436
8108
  );
8437
8109
  const closedStatus = { closedIds: closed.closedIds, source: closed.source, note: closed.note };
8438
8110
  const openLeaf = openOnly || opts.countOnly === true ? [openStatusFilter(closed.closedIds)] : [];
@@ -8519,74 +8191,483 @@ async function forResource(_resource, resourceIDIn, http, opts = {}) {
8519
8191
  closedStatus
8520
8192
  };
8521
8193
  }
8522
-
8523
- // src/resources/special/tickets/lineOfBusiness.ts
8524
- var OP3 = "tickets.lineOfBusiness";
8525
- function positiveId2(value, name) {
8526
- const n = typeof value === "string" && /^[0-9]+$/.test(value.trim()) ? Number(value) : value;
8527
- if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) {
8528
- throw new AutotaskConfigError(OP3 + ": " + name + " must be a positive integer id (got " + String(value) + ")", { operation: OP3 });
8529
- }
8530
- return n;
8194
+
8195
+ // src/resources/special/tickets/common.ts
8196
+ async function fetchTicket(http, id, operation) {
8197
+ const res = await http.request({
8198
+ method: "GET",
8199
+ path: `/V1.0/Tickets/${id}`,
8200
+ operation
8201
+ });
8202
+ const body = res;
8203
+ if (body === null || !("item" in body)) {
8204
+ throw new NotFoundError(`Ticket ${id} not found (unexpected envelope shape)`, { resourceIds: [id], details: { idRefs: [{ entity: "ticket", id }] } });
8205
+ }
8206
+ if (body.item === null || body.item === void 0) throw new NotFoundError(`Ticket ${id} not found`, { resourceIds: [id], details: { idRefs: [{ entity: "ticket", id }] } });
8207
+ return body.item;
8208
+ }
8209
+ async function ticketQueryItems(http, path, filter, maxRecords, operation) {
8210
+ const res = await http.request({
8211
+ method: "POST",
8212
+ idempotent: true,
8213
+ path,
8214
+ body: { MaxRecords: maxRecords, filter },
8215
+ operation
8216
+ });
8217
+ return unwrapItems(res).items;
8218
+ }
8219
+ function truncate(text, limit) {
8220
+ const full = text ?? "";
8221
+ if (full.length <= limit) return { value: full, truncated: false, length: full.length };
8222
+ return { value: `${full.slice(0, limit)}\u2026`, truncated: true, length: full.length };
8223
+ }
8224
+
8225
+ // src/resources/special/tickets/childRead.ts
8226
+ function isRouteUnavailable(err) {
8227
+ return isAutotaskError(err) && (err.httpStatus === 404 || err.httpStatus === 500);
8228
+ }
8229
+ function routeFact(route, path, err) {
8230
+ return {
8231
+ route,
8232
+ path,
8233
+ httpStatus: isAutotaskError(err) && typeof err.httpStatus === "number" ? err.httpStatus : null
8234
+ };
8235
+ }
8236
+ function childDeclaresQuery(resourceKey) {
8237
+ return CAPABILITIES.some(
8238
+ (c) => c.id === `${resourceKey}.query` && (c.status === "implemented" || c.status === "tested")
8239
+ );
8240
+ }
8241
+ function planScopedPath(resourceKey, ticketId) {
8242
+ if (!childDeclaresQuery(resourceKey)) return null;
8243
+ const entry = RELATIONSHIP_MAP[resourceKey];
8244
+ const parent = entry === void 0 ? null : entry.parent;
8245
+ if (parent === null || parent.resource !== "tickets" || parent.segment === null || parent.field === null) return null;
8246
+ const parentPath = RELATIONSHIP_MAP[parent.resource]?.path;
8247
+ if (parentPath === null || parentPath === void 0) return null;
8248
+ return `/V1.0/${parentPath}/${ticketId}/${parent.segment}`;
8249
+ }
8250
+ function scopedRowMatches(row, filter) {
8251
+ let conds;
8252
+ if (Array.isArray(filter)) conds = filter;
8253
+ else if (filter !== null && typeof filter === "object" && Array.isArray(filter.filter)) {
8254
+ conds = filter.filter;
8255
+ }
8256
+ if (conds === void 0) return true;
8257
+ for (const raw of conds) {
8258
+ if (raw === null || typeof raw !== "object") return false;
8259
+ const cond = raw;
8260
+ if (typeof cond.field !== "string" || typeof cond.op !== "string") return false;
8261
+ if (cond.field === "ticketID") continue;
8262
+ const value = row[cond.field];
8263
+ if (typeof value !== "string") return false;
8264
+ const want = String(cond.value ?? "");
8265
+ if (cond.op === "eq" && value !== want) return false;
8266
+ if (cond.op === "gt" && !(value > want)) return false;
8267
+ if (cond.op === "lt" && !(value < want)) return false;
8268
+ if (cond.op !== "eq" && cond.op !== "gt" && cond.op !== "lt") return false;
8269
+ }
8270
+ return true;
8271
+ }
8272
+ var SCOPED_FALLBACK_PAGE_CAP = 10;
8273
+ async function scopedCollectionRead(http, startPath, limit, operation, accept) {
8274
+ let target = startPath;
8275
+ const items = [];
8276
+ let guard = false;
8277
+ let pages = 0;
8278
+ let nextCursor = null;
8279
+ for (; ; ) {
8280
+ if (pages >= SCOPED_FALLBACK_PAGE_CAP) {
8281
+ guard = true;
8282
+ break;
8283
+ }
8284
+ const body = await http.request({ method: "GET", path: target, operation });
8285
+ const { items: pageItems, pageDetails } = unwrapItems(body, operation);
8286
+ nextCursor = pageDetails.nextPageUrl;
8287
+ pages += 1;
8288
+ const window = pageItems.slice(0, Math.max(0, limit - items.length));
8289
+ for (const row of window) if (accept(row)) items.push(row);
8290
+ if (items.length >= limit) break;
8291
+ if (nextCursor === null || pageItems.length === 0) break;
8292
+ target = cursorToPath(http.baseUrl, nextCursor);
8293
+ }
8294
+ const capped = items.length >= limit || guard && nextCursor !== null;
8295
+ return { items, capped };
8296
+ }
8297
+ async function ticketChildRead(http, spec, ticketId) {
8298
+ try {
8299
+ const items = await ticketQueryItems(http, spec.globalPath, spec.filter, spec.limit, spec.operation);
8300
+ return { items, capped: items.length >= spec.limit, status: "ok", routeFacts: [] };
8301
+ } catch (err) {
8302
+ if (!isRouteUnavailable(err)) throw err;
8303
+ const facts = [routeFact("global", spec.globalPath, err)];
8304
+ const scopedPath = planScopedPath(spec.resourceKey, ticketId);
8305
+ if (scopedPath === null) return { items: [], capped: false, status: "miss", routeFacts: facts };
8306
+ try {
8307
+ const read = await scopedCollectionRead(http, scopedPath, spec.limit, spec.operation, (row) => scopedRowMatches(row, spec.filter));
8308
+ return { ...read, status: "ok-scoped", routeFacts: [] };
8309
+ } catch (scopedErr) {
8310
+ facts.push(routeFact("scoped", scopedPath, scopedErr));
8311
+ return { items: [], capped: false, status: "miss", routeFacts: facts };
8312
+ }
8313
+ }
8314
+ }
8315
+ function applyRouteStatus(part, read) {
8316
+ if (read.status === "ok-scoped") part.route = "scoped";
8317
+ if (read.status === "miss") part.routeFacts = read.routeFacts;
8318
+ }
8319
+
8320
+ // src/resources/special/tickets/fullContext.ts
8321
+ var DEFAULT_PART_LIMIT = 50;
8322
+ function partLimit(value, name) {
8323
+ const n = value === void 0 ? DEFAULT_PART_LIMIT : Math.trunc(value);
8324
+ if (!Number.isInteger(n) || n < 1) {
8325
+ throw new AutotaskConfigError(`fullContext: ${name} must be an integer >= 1 (got ${String(value)})`);
8326
+ }
8327
+ return n;
8328
+ }
8329
+ function servedTextRecord(row, textField, limit) {
8330
+ const text = row[textField];
8331
+ if (typeof text !== "string" || text.length <= limit) return { ...row };
8332
+ const out = { ...row };
8333
+ out[`${textField}Truncated`] = true;
8334
+ out[textField] = `${text.slice(0, limit)}\u2026`;
8335
+ return out;
8336
+ }
8337
+ async function addCompositeNames(http, ctx, entriesPart, result, opts) {
8338
+ const builder = createNamePlan(TICKET_LABEL_FIELDS);
8339
+ const t = ctx.ticket;
8340
+ builder.addResource(t["creatorResourceID"]);
8341
+ builder.addFk("contract", t["contractID"]);
8342
+ builder.addFk("billingCode", t["billingCodeID"]);
8343
+ builder.addFk("role", t["assignedResourceRoleID"]);
8344
+ const ticketLobPointer = vendorId(t["organizationalLevelAssociationID"]);
8345
+ if (ticketLobPointer !== void 0) {
8346
+ builder.addLineOfBusiness(ticketLobPointer, { source: "ticket" });
8347
+ } else {
8348
+ builder.addLineOfBusinessFromContract(t["contractID"]);
8349
+ }
8350
+ for (const e of entriesPart.items) {
8351
+ builder.addResource(e["resourceID"]);
8352
+ builder.addFk("role", e["roleID"]);
8353
+ builder.addFk("billingCode", e["billingCodeID"]);
8354
+ builder.addFk("contract", e["contractID"]);
8355
+ }
8356
+ const names = await resolveCompositeNames(http, builder.plan(), { maxNameReads: opts.maxNameReads });
8357
+ const ticketRow = { ...ctx.ticket };
8358
+ addNameSibling(ticketRow, "creatorResourceID", (v) => names.resourceName(v));
8359
+ addNameSibling(ticketRow, "contractID", (v) => names.recordName("contract", v));
8360
+ addNameSibling(ticketRow, "billingCodeID", (v) => names.recordName("billingCode", v));
8361
+ addNameSibling(ticketRow, "assignedResourceRoleID", (v) => names.recordName("role", v));
8362
+ for (const f of TICKET_LABEL_FIELDS) addLabelSibling(ticketRow, f, names);
8363
+ result.ticket = ticketRow;
8364
+ result.timeEntries.items = entriesPart.items.map((e) => {
8365
+ const row = { ...e };
8366
+ addNameSibling(row, "resourceID", (v) => names.resourceName(v));
8367
+ addNameSibling(row, "roleID", (v) => names.recordName("role", v));
8368
+ addNameSibling(row, "billingCodeID", (v) => names.recordName("billingCode", v));
8369
+ addNameSibling(row, "contractID", (v) => names.recordName("contract", v));
8370
+ return row;
8371
+ });
8372
+ result.names = names.section;
8373
+ result.lineOfBusiness = names.lineOfBusiness ?? {
8374
+ association: null,
8375
+ organizationalLevel2: null,
8376
+ organizationalLevel1: null,
8377
+ markers: { association: "missing", organizationalLevel2: "missing", organizationalLevel1: "missing" }
8378
+ };
8379
+ result.truncated = result.truncated || names.section.truncated;
8380
+ }
8381
+ async function fullContext(resource, id, opts = {}) {
8382
+ const http = resource.http;
8383
+ const noteLimit = partLimit(opts.noteLimit, "noteLimit");
8384
+ const timeEntryLimit = partLimit(opts.timeEntryLimit, "timeEntryLimit");
8385
+ const textLimit = partLimit(opts.textLimit, "textLimit");
8386
+ const ticketId = parseIdentifier(id, "id");
8387
+ const ctx = await resource.getContext(ticketId);
8388
+ const op = "tickets.fullContext";
8389
+ const ticketFilter = [{ field: "ticketID", op: "eq", value: ticketId }];
8390
+ const notes = await ticketChildRead(http, { globalPath: "/V1.0/TicketNotes/query", resourceKey: "ticket_notes", filter: ticketFilter, limit: noteLimit, operation: op }, ticketId);
8391
+ const entries = await ticketChildRead(http, { globalPath: "/V1.0/TimeEntries/query", resourceKey: "time_entries", filter: ticketFilter, limit: timeEntryLimit, operation: op }, ticketId);
8392
+ const notesRows = notes.items.map((n) => servedTextRecord(n, "description", textLimit));
8393
+ const notesPart = { items: notesRows, capped: notes.capped };
8394
+ applyRouteStatus(notesPart, notes);
8395
+ const entriesPart = { items: entries.items, capped: entries.capped };
8396
+ applyRouteStatus(entriesPart, entries);
8397
+ const notesTextTruncated = notesRows.some((n) => n.descriptionTruncated === true);
8398
+ let attachments = { ids: [], names: [], capped: false };
8399
+ let attachmentsStatus = "ok";
8400
+ if (opts.includeAttachments !== false) {
8401
+ const read = await ticketChildRead(http, { globalPath: "/V1.0/TicketAttachments/query", resourceKey: "ticket_attachments", filter: ticketFilter, limit: DEFAULT_PART_LIMIT, operation: op }, ticketId);
8402
+ attachmentsStatus = read.status;
8403
+ attachments = {
8404
+ ids: read.items.map((r) => Number(r.id)),
8405
+ names: read.items.map((r) => typeof r.title === "string" ? r.title : null),
8406
+ capped: read.capped
8407
+ };
8408
+ applyRouteStatus(attachments, read);
8409
+ }
8410
+ const result = {
8411
+ ticket: ctx.ticket,
8412
+ company: ctx.company,
8413
+ contact: ctx.contact,
8414
+ assignedResource: ctx.resource,
8415
+ project: ctx.project,
8416
+ notes: notesPart,
8417
+ timeEntries: entriesPart,
8418
+ attachments,
8419
+ truncated: notesPart.capped || entriesPart.capped || attachments.capped || notesTextTruncated,
8420
+ parts: {
8421
+ company: ctx.company !== null ? "ok" : "absent",
8422
+ contact: ctx.contact !== null ? "ok" : "absent",
8423
+ assignedResource: ctx.resource !== null ? "ok" : "absent",
8424
+ project: ctx.project !== null ? "ok" : "absent",
8425
+ notes: notes.status,
8426
+ timeEntries: entries.status,
8427
+ attachments: attachmentsStatus
8428
+ }
8429
+ };
8430
+ if (opts.includeHistories === true) {
8431
+ const historiesRead = await ticketChildRead(http, { globalPath: "/V1.0/TicketHistory/query", resourceKey: "ticket_history", filter: ticketFilter, limit: DEFAULT_PART_LIMIT, operation: op }, ticketId);
8432
+ const historyRows = historiesRead.items.map((h) => servedTextRecord(h, "detail", textLimit));
8433
+ const part = { items: historyRows, capped: historiesRead.capped };
8434
+ applyRouteStatus(part, historiesRead);
8435
+ result.histories = part;
8436
+ result.parts.histories = historiesRead.status;
8437
+ result.truncated = result.truncated || part.capped || historyRows.some((h) => h.detailTruncated === true);
8438
+ }
8439
+ if (opts.names === true) await addCompositeNames(http, ctx, entriesPart, result, opts);
8440
+ return result;
8441
+ }
8442
+
8443
+ // src/resources/special/tickets/lineOfBusiness.ts
8444
+ var OP5 = "tickets.lineOfBusiness";
8445
+ function positiveId2(value, name) {
8446
+ const n = typeof value === "string" && /^[0-9]+$/.test(value.trim()) ? Number(value) : value;
8447
+ if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) {
8448
+ throw new AutotaskConfigError(OP5 + ": " + name + " must be a positive integer id (got " + String(value) + ")", { operation: OP5 });
8449
+ }
8450
+ return n;
8451
+ }
8452
+ async function lineOfBusiness(resource, ticketIdIn, associationIdIn) {
8453
+ const http = resource.http;
8454
+ const ticketId = positiveId2(ticketIdIn, "ticketId");
8455
+ let associationId;
8456
+ let associationSource = null;
8457
+ let contractId = null;
8458
+ let contractUnread = false;
8459
+ if (associationIdIn !== void 0) {
8460
+ associationId = positiveId2(associationIdIn, "associationId");
8461
+ associationSource = "argument";
8462
+ } else {
8463
+ const ticket = await resource.get(ticketId);
8464
+ associationId = vendorId(ticket.organizationalLevelAssociationID);
8465
+ if (associationId !== void 0) {
8466
+ associationSource = "ticket";
8467
+ } else {
8468
+ const contractRef = vendorId(ticket.contractID);
8469
+ if (contractRef !== void 0) {
8470
+ const client = http.client;
8471
+ if (client === void 0) {
8472
+ throw new AutotaskConfigError(OP5 + ": the transport is not wired to an AutotaskClient (the composite must run through the client)", { operation: OP5 });
8473
+ }
8474
+ try {
8475
+ const contract = await client.contracts.get(contractRef);
8476
+ contractId = contractRef;
8477
+ associationId = vendorId(contract.organizationalLevelAssociationID);
8478
+ if (associationId !== void 0) associationSource = "contract";
8479
+ } catch (e) {
8480
+ if (e instanceof NotFoundError) {
8481
+ contractId = contractRef;
8482
+ } else if (isAutotaskError(e) && e.code !== "ABORTED") {
8483
+ contractId = contractRef;
8484
+ contractUnread = true;
8485
+ } else {
8486
+ throw e;
8487
+ }
8488
+ }
8489
+ }
8490
+ }
8491
+ }
8492
+ if (associationId === void 0) {
8493
+ return {
8494
+ ticketId,
8495
+ associationSource,
8496
+ contractId,
8497
+ association: null,
8498
+ organizationalLevel2: null,
8499
+ organizationalLevel1: null,
8500
+ markers: { association: contractUnread ? "unresolved" : "missing", organizationalLevel2: "missing", organizationalLevel1: "missing" }
8501
+ };
8502
+ }
8503
+ const chain = await resolveLineOfBusinessChain(http, associationId, { budget: 3, operation: OP5, onError: "propagate" });
8504
+ const { association, organizationalLevel2, organizationalLevel1, markers } = chain.lineOfBusiness;
8505
+ return { ticketId, associationSource, contractId, association, organizationalLevel2, organizationalLevel1, markers };
8506
+ }
8507
+
8508
+ // src/resources/special/tickets/sla.ts
8509
+ var MS = 864e5;
8510
+ var SLA_PAGE = 100;
8511
+ async function slaHealthCheck(_resource, id, opts = {}) {
8512
+ const http = _resource.http;
8513
+ void opts;
8514
+ const ticketId = parseIdentifier(id, "id");
8515
+ await fetchTicket(http, ticketId, "tickets.slaHealthCheck");
8516
+ const rows = await ticketQueryItems(
8517
+ http,
8518
+ "/V1.0/ServiceLevelAgreementResults/query",
8519
+ [{ field: "ticketID", op: "eq", value: ticketId }],
8520
+ SLA_PAGE,
8521
+ "tickets.slaHealthCheck"
8522
+ );
8523
+ const slas = rows.map((row) => {
8524
+ const bool = (v) => typeof v === "boolean" ? v : null;
8525
+ const num = (v) => typeof v === "number" ? v : null;
8526
+ const milestones = [
8527
+ { name: "firstResponse", met: bool(row.isFirstResponseMet), elapsedHours: num(row.firstResponseElapsedHours) },
8528
+ { name: "resolution", met: bool(row.isResolutionMet), elapsedHours: num(row.resolutionElapsedHours) },
8529
+ { name: "resolutionPlan", met: bool(row.isResolutionPlanMet), elapsedHours: num(row.resolutionPlanElapsedHours) }
8530
+ ];
8531
+ return { id: Number(row.id), name: row.serviceLevelAgreementName ?? null, milestones };
8532
+ });
8533
+ const healthy = slas.every((sla) => sla.milestones.every((m) => m.met === true));
8534
+ return { ticketId, slaCount: slas.length, healthy, slas, truncated: rows.length >= SLA_PAGE };
8535
+ }
8536
+ async function summary(_resource, id, opts = {}) {
8537
+ const http = _resource.http;
8538
+ const limit = opts.summaryTextLimit === void 0 ? 500 : Math.trunc(opts.summaryTextLimit);
8539
+ if (!Number.isInteger(limit) || limit < 1) {
8540
+ throw new AutotaskConfigError(`summaryTextLimit must be an integer >= 1 (got ${String(opts.summaryTextLimit)})`);
8541
+ }
8542
+ const ticketId = parseIdentifier(id, "id");
8543
+ const ticket = await fetchTicket(http, ticketId, "tickets.summary");
8544
+ const desc = truncate(ticket.description, limit);
8545
+ const now = opts.now ?? /* @__PURE__ */ new Date();
8546
+ const ageDays = typeof ticket.createDate === "string" && ticket.createDate !== "" ? Math.max(0, Math.floor((now.getTime() - Date.parse(ticket.createDate)) / MS)) : null;
8547
+ const pick = (v) => typeof v === "number" ? v : null;
8548
+ const str = (v) => typeof v === "string" && v !== "" ? v : null;
8549
+ return {
8550
+ ticketId,
8551
+ ticketNumber: str(ticket.ticketNumber),
8552
+ title: str(ticket.title),
8553
+ description: desc.value,
8554
+ descriptionLength: desc.length,
8555
+ descriptionTruncated: desc.truncated,
8556
+ status: pick(ticket.status),
8557
+ priority: pick(ticket.priority),
8558
+ companyID: pick(ticket.companyID),
8559
+ contactID: pick(ticket.contactID),
8560
+ assignedResourceID: pick(ticket.assignedResourceID),
8561
+ projectID: pick(ticket.projectID),
8562
+ createDate: str(ticket.createDate),
8563
+ dueDateTime: str(ticket.dueDateTime),
8564
+ ageDays
8565
+ };
8566
+ }
8567
+
8568
+ // src/resources/special/tickets/statusSemantics.ts
8569
+ async function statusSemantics(_resource, opts = {}) {
8570
+ const http = _resource.http;
8571
+ return resolveTicketStatusSemantics(http, opts);
8572
+ }
8573
+
8574
+ // src/resources/special/tickets/timeline.ts
8575
+ function validateTimelineLimits(opts) {
8576
+ const limit = opts.limit === void 0 ? 100 : Math.trunc(opts.limit);
8577
+ if (!Number.isInteger(limit) || limit < 1) throw new AutotaskConfigError(`limit must be an integer >= 1 (got ${String(opts.limit)})`);
8578
+ const bounded = Math.min(limit, 500);
8579
+ const textLimit = opts.textLimit === void 0 ? 500 : Math.trunc(opts.textLimit);
8580
+ if (!Number.isInteger(textLimit) || textLimit < 1) {
8581
+ throw new AutotaskConfigError(`textLimit must be an integer >= 1 (got ${String(opts.textLimit)})`);
8582
+ }
8583
+ return { bounded, textLimit };
8584
+ }
8585
+ function noteEvent(n, textLimit) {
8586
+ const text = truncate(typeof n.description === "string" ? n.description : null, textLimit);
8587
+ return {
8588
+ kind: "note",
8589
+ id: Number(n.id),
8590
+ date: typeof n.createDateTime === "string" ? n.createDateTime : null,
8591
+ text: text.value || null,
8592
+ title: typeof n.title === "string" ? n.title : null,
8593
+ hours: null,
8594
+ action: null,
8595
+ textTruncated: text.truncated
8596
+ };
8597
+ }
8598
+ function timeEntryEvent(t) {
8599
+ return {
8600
+ kind: "timeEntry",
8601
+ id: Number(t.id),
8602
+ date: typeof t.dateWorked === "string" ? t.dateWorked : null,
8603
+ text: null,
8604
+ title: null,
8605
+ hours: typeof t.hoursWorked === "number" ? t.hoursWorked : null,
8606
+ action: null,
8607
+ textTruncated: false
8608
+ };
8609
+ }
8610
+ function historyEvent(h, textLimit) {
8611
+ const text = truncate(typeof h.detail === "string" ? h.detail : null, textLimit);
8612
+ return {
8613
+ kind: "history",
8614
+ id: Number(h.id),
8615
+ date: typeof h.date === "string" ? h.date : null,
8616
+ text: text.value || null,
8617
+ title: null,
8618
+ hours: null,
8619
+ action: typeof h.action === "string" ? h.action : null,
8620
+ textTruncated: text.truncated
8621
+ };
8531
8622
  }
8532
- async function lineOfBusiness(resource, ticketIdIn, http, associationIdIn) {
8533
- const ticketId = positiveId2(ticketIdIn, "ticketId");
8534
- let associationId;
8535
- let associationSource = null;
8536
- let contractId = null;
8537
- let contractUnread = false;
8538
- if (associationIdIn !== void 0) {
8539
- associationId = positiveId2(associationIdIn, "associationId");
8540
- associationSource = "argument";
8541
- } else {
8542
- const ticket = await resource.get(ticketId);
8543
- associationId = vendorId(ticket.organizationalLevelAssociationID);
8544
- if (associationId !== void 0) {
8545
- associationSource = "ticket";
8546
- } else {
8547
- const contractRef = vendorId(ticket.contractID);
8548
- if (contractRef !== void 0) {
8549
- const client = http.client;
8550
- if (client === void 0) {
8551
- throw new AutotaskConfigError(OP3 + ": the transport is not wired to an AutotaskClient (the composite must run through the client)", { operation: OP3 });
8552
- }
8553
- try {
8554
- const contract = await client.contracts.get(contractRef);
8555
- contractId = contractRef;
8556
- associationId = vendorId(contract.organizationalLevelAssociationID);
8557
- if (associationId !== void 0) associationSource = "contract";
8558
- } catch (e) {
8559
- if (e instanceof NotFoundError) {
8560
- contractId = contractRef;
8561
- } else if (isAutotaskError(e) && e.code !== "ABORTED") {
8562
- contractId = contractRef;
8563
- contractUnread = true;
8564
- } else {
8565
- throw e;
8566
- }
8567
- }
8568
- }
8569
- }
8623
+ function compareEventDates(a, b) {
8624
+ const da = a.date ?? "";
8625
+ const db = b.date ?? "";
8626
+ if (da === "" && db === "") return 0;
8627
+ if (da === "") return 1;
8628
+ if (db === "") return -1;
8629
+ return da < db ? -1 : da > db ? 1 : 0;
8630
+ }
8631
+ async function timeline(_resource, id, opts = {}) {
8632
+ const http = _resource.http;
8633
+ const { bounded, textLimit } = validateTimelineLimits(opts);
8634
+ const ticketId = parseIdentifier(id, "id");
8635
+ await fetchTicket(http, ticketId, "tickets.timeline");
8636
+ const dateFilters = (field) => {
8637
+ const f = [{ field: "ticketID", op: "eq", value: ticketId }];
8638
+ if (opts.since !== void 0) f.push({ field, op: "gt", value: opts.since });
8639
+ if (opts.until !== void 0) f.push({ field, op: "lt", value: opts.until });
8640
+ return f;
8641
+ };
8642
+ const events = [];
8643
+ const cappedSources = [];
8644
+ const notes = await ticketChildRead(http, { globalPath: "/V1.0/TicketNotes/query", resourceKey: "ticket_notes", filter: dateFilters("createDateTime"), limit: bounded, operation: "tickets.timeline" }, ticketId);
8645
+ if (notes.capped) cappedSources.push("notes");
8646
+ for (const n of notes.items) events.push(noteEvent(n, textLimit));
8647
+ const entries = await ticketChildRead(http, { globalPath: "/V1.0/TimeEntries/query", resourceKey: "time_entries", filter: dateFilters("dateWorked"), limit: bounded, operation: "tickets.timeline" }, ticketId);
8648
+ if (entries.capped) cappedSources.push("timeEntries");
8649
+ for (const t of entries.items) events.push(timeEntryEvent(t));
8650
+ let historiesRead;
8651
+ if (opts.includeHistories) {
8652
+ historiesRead = await ticketChildRead(http, { globalPath: "/V1.0/TicketHistory/query", resourceKey: "ticket_history", filter: dateFilters("date"), limit: bounded, operation: "tickets.timeline" }, ticketId);
8653
+ if (historiesRead.capped) cappedSources.push("histories");
8654
+ for (const h of historiesRead.items) events.push(historyEvent(h, textLimit));
8570
8655
  }
8571
- if (associationId === void 0) {
8572
- return {
8573
- ticketId,
8574
- associationSource,
8575
- contractId,
8576
- association: null,
8577
- organizationalLevel2: null,
8578
- organizationalLevel1: null,
8579
- markers: { association: contractUnread ? "unresolved" : "missing", organizationalLevel2: "missing", organizationalLevel1: "missing" }
8580
- };
8656
+ events.sort(compareEventDates);
8657
+ const capped = events.slice(0, bounded);
8658
+ const result = {
8659
+ ticketId,
8660
+ count: capped.length,
8661
+ events: capped,
8662
+ truncated: cappedSources.length > 0 || events.length > bounded,
8663
+ cappedSources
8664
+ };
8665
+ if (notes.status !== "ok" || entries.status !== "ok" || historiesRead !== void 0 && historiesRead.status !== "ok") {
8666
+ const routes = { notes: notes.status, timeEntries: entries.status };
8667
+ if (historiesRead !== void 0) routes.histories = historiesRead.status;
8668
+ result.sourceRoutes = routes;
8581
8669
  }
8582
- const chain = await resolveLineOfBusinessChain(http, associationId, { budget: 3, operation: OP3, onError: "propagate" });
8583
- const { association, organizationalLevel2, organizationalLevel1, markers } = chain.lineOfBusiness;
8584
- return { ticketId, associationSource, contractId, association, organizationalLevel2, organizationalLevel1, markers };
8585
- }
8586
-
8587
- // src/resources/special/tickets/statusSemantics.ts
8588
- async function statusSemantics(_resource, opts = {}, http) {
8589
- return resolveTicketStatusSemantics(http, opts);
8670
+ return result;
8590
8671
  }
8591
8672
 
8592
8673
  // src/resources/tickets.ts
@@ -8670,40 +8751,36 @@ var TicketsResource = class extends BaseResource {
8670
8751
  const project = await this.contextFetch("/V1.0/Projects", root["projectID"]);
8671
8752
  return { ticket, company, contact, resource, project };
8672
8753
  }
8673
- /** Node-parity special op (hand-written in src/resources/special/tickets.ts). */
8754
+ /** Node-parity special op (hand-written in src/resources/special/tickets/changeStatus.ts). */
8674
8755
  changeStatus(id, targetStatus, fields, opts = {}) {
8675
- return changeStatus(this, id, targetStatus, fields, opts, this.http);
8676
- }
8677
- /** Node-parity special op (hand-written in src/resources/special/tickets.ts). */
8678
- openWithNoteAndAssign(params, note, assign, opts = {}) {
8679
- return openWithNoteAndAssign(this, params, note, assign, opts, this.http);
8756
+ return changeStatus(this, id, targetStatus, fields, opts);
8680
8757
  }
8681
- /** Node-parity special op (hand-written in src/resources/special/tickets.ts). */
8758
+ /** Node-parity special op (hand-written in src/resources/special/tickets/sla.ts). */
8682
8759
  slaHealthCheck(id, opts) {
8683
- return slaHealthCheck(this, id, opts, this.http);
8760
+ return slaHealthCheck(this, id, opts);
8684
8761
  }
8685
- /** Node-parity special op (hand-written in src/resources/special/tickets.ts). */
8762
+ /** Node-parity special op (hand-written in src/resources/special/tickets/sla.ts). */
8686
8763
  summary(id, opts) {
8687
- return summary(this, id, opts, this.http);
8764
+ return summary(this, id, opts);
8688
8765
  }
8689
- /** Node-parity special op (hand-written in src/resources/special/tickets.ts). */
8766
+ /** Node-parity special op (hand-written in src/resources/special/tickets/timeline.ts). */
8690
8767
  timeline(id, opts) {
8691
- return timeline(this, id, opts, this.http);
8768
+ return timeline(this, id, opts);
8692
8769
  }
8693
- /** Node-parity special op (hand-written in src/resources/special/tickets.ts). */
8770
+ /** Node-parity special op (hand-written in src/resources/special/tickets/fullContext.ts). */
8694
8771
  fullContext(id, opts) {
8695
- return fullContext(this, id, opts, this.http);
8772
+ return fullContext(this, id, opts);
8696
8773
  }
8697
- /** Node-parity special op (hand-written in src/resources/special/tickets.ts). */
8774
+ /** Node-parity special op (hand-written in src/resources/special/tickets/statusSemantics.ts). */
8698
8775
  statusSemantics(opts) {
8699
- return statusSemantics(this, opts, this.http);
8776
+ return statusSemantics(this, opts);
8700
8777
  }
8701
8778
  forResource(resourceID, opts) {
8702
- return forResource(this, resourceID, this.http, opts);
8779
+ return forResource(this, resourceID, opts);
8703
8780
  }
8704
- /** Node-parity special op (hand-written in src/resources/special/tickets.ts). */
8781
+ /** Node-parity special op (hand-written in src/resources/special/tickets/lineOfBusiness.ts). */
8705
8782
  lineOfBusiness(ticketId, associationId) {
8706
- return lineOfBusiness(this, ticketId, this.http, associationId);
8783
+ return lineOfBusiness(this, ticketId, associationId);
8707
8784
  }
8708
8785
  };
8709
8786
 
@@ -8783,6 +8860,356 @@ var TicketWebhooksResource = class extends BaseResource {
8783
8860
  }
8784
8861
  };
8785
8862
 
8863
+ // src/resources/special/time_entries/move.ts
8864
+ var OP6 = "time_entries.moveToTicket";
8865
+ var COPY2 = ["resourceID", "roleID", "billingCodeID", "dateWorked", "startDateTime", "endDateTime", "hoursWorked", "offsetHours", "summaryNotes", "internalNotes", "isNonBillable", "showOnInvoice"];
8866
+ var CONTRACT_SCOPED2 = ["contractID", "contractServiceID", "contractServiceBundleID"];
8867
+ async function readTimeEntry(http, id, retries) {
8868
+ try {
8869
+ const res = await http.request({ method: "GET", path: `/V1.0/TimeEntries/${id}`, operation: OP6, maxRetries: retries });
8870
+ return res?.item ?? null;
8871
+ } catch (e) {
8872
+ if (isAutotaskError(e) && (e.httpStatus === 404 || e.code === "NOT_FOUND")) return null;
8873
+ throw e;
8874
+ }
8875
+ }
8876
+ async function checkSource2({ http, source, sourceId, toTicketId, retries }) {
8877
+ const ids = { sourceId, toTicketId };
8878
+ if (source.taskID !== null && source.taskID !== void 0) {
8879
+ throw refuse("MOVE_SOURCE_UNSUPPORTED", `${OP6}: time entry ${sourceId} is on a task \u2014 only ticket time entries are moved`, ids, OP6);
8880
+ }
8881
+ if (source.billingApprovalDateTime || source.billingApprovalResourceID || source.billingApprovalLevelMostRecent) {
8882
+ throw refuse("MOVE_BLOCKED_APPROVED", `${OP6}: time entry ${sourceId} has billing approval set \u2014 move it in the Autotask UI`, ids, OP6, { manualAction: { action: "delete_source", uiLink: uiLinkFor(http, "time_entries", sourceId), reason: "approved entries are not moved by the API" } });
8883
+ }
8884
+ let billing;
8885
+ try {
8886
+ billing = await queryRows(http, "BillingItems", [{ field: "timeEntryID", op: "eq", value: sourceId }], OP6, retries);
8887
+ } catch {
8888
+ throw refuse("MOVE_BLOCKED_BILLED", `${OP6}: the billing items of time entry ${sourceId} could not be read \u2014 refused (fails closed)`, ids, OP6, { manualAction: { action: "delete_source", uiLink: uiLinkFor(http, "time_entries", sourceId), reason: "billing state unreadable" } });
8889
+ }
8890
+ if (billing.some((b) => b.invoiceID || b.postedDate)) {
8891
+ throw refuse("MOVE_BLOCKED_BILLED", `${OP6}: time entry ${sourceId} is billed or posted \u2014 move it in the Autotask UI`, ids, OP6, { manualAction: { action: "delete_source", uiLink: uiLinkFor(http, "time_entries", sourceId), reason: "billed entries are not moved by the API" } });
8892
+ }
8893
+ const attachments = await queryRows(http, "TimeEntryAttachments", [{ field: "timeEntryID", op: "eq", value: sourceId }], OP6, retries);
8894
+ if (attachments.length > 0) {
8895
+ throw refuse("MOVE_BLOCKED_ATTACHMENTS", `${OP6}: time entry ${sourceId} has attachments, which a delete would destroy \u2014 move it in the Autotask UI`, ids, OP6, { manualAction: { action: "delete_source", uiLink: uiLinkFor(http, "time_entries", sourceId), reason: "entries with attachments are not moved by the API" } });
8896
+ }
8897
+ }
8898
+ var SPEC2 = {
8899
+ operation: OP6,
8900
+ entity: "time_entry",
8901
+ readSource: readTimeEntry,
8902
+ sourceTicketId: (s) => typeof s.ticketID === "number" ? s.ticketID : null,
8903
+ dateField: "dateWorked",
8904
+ checkSource: checkSource2,
8905
+ buildPayload(source, toTicketId, { sameScope }) {
8906
+ const body = { ticketID: toTicketId };
8907
+ for (const k of COPY2) if (source[k] !== void 0 && source[k] !== null) body[k] = source[k];
8908
+ if (sameScope) {
8909
+ for (const k of CONTRACT_SCOPED2) if (source[k] !== void 0 && source[k] !== null) body[k] = source[k];
8910
+ }
8911
+ return body;
8912
+ },
8913
+ createPath: () => "/V1.0/TimeEntries",
8914
+ deletePath: (id) => `/V1.0/TimeEntries/${id}`,
8915
+ readById: (http, id) => readTimeEntry(http, id),
8916
+ verifyFields: ["resourceID", "roleID", "billingCodeID", "dateWorked", "startDateTime", "endDateTime", "hoursWorked", "summaryNotes", "internalNotes", "isNonBillable"],
8917
+ guardFields: ["ticketID", "resourceID", "roleID", "dateWorked", "startDateTime", "endDateTime", "hoursWorked", "summaryNotes", "billingApprovalDateTime", "billingApprovalResourceID", "lastModifiedDateTime"],
8918
+ twinQuery: (p) => ({
8919
+ resource: "TimeEntries",
8920
+ filter: [
8921
+ { field: "ticketID", op: "eq", value: p.ticketID },
8922
+ { field: "resourceID", op: "eq", value: p.resourceID },
8923
+ { field: "dateWorked", op: "eq", value: p.dateWorked },
8924
+ { field: "hoursWorked", op: "eq", value: p.hoursWorked }
8925
+ ]
8926
+ }),
8927
+ manualLink: (http, source) => uiLinkFor(http, "time_entries", Number(source.id))
8928
+ };
8929
+ function moveToTicket2(resource, sourceId, toTicketId, opts) {
8930
+ return runMove(resource.http, SPEC2, sourceId, toTicketId, opts);
8931
+ }
8932
+
8933
+ // src/resources/special/time_entries/posted.ts
8934
+ async function postedStateOf(http, entries, op, beforeRequest) {
8935
+ const states = /* @__PURE__ */ new Map();
8936
+ const ids = [...new Set(entries.map((e) => e.id))];
8937
+ let incomplete = false;
8938
+ for (let c = 0; c < ids.length; c += FETCH_BY_IDS_CHUNK_SIZE) {
8939
+ const chunk = ids.slice(c, c + FETCH_BY_IDS_CHUNK_SIZE);
8940
+ beforeRequest?.();
8941
+ const res = await http.request({
8942
+ method: "POST",
8943
+ idempotent: true,
8944
+ path: "/V1.0/BillingItems/query",
8945
+ body: { MaxRecords: 500, IncludeFields: ["id", "timeEntryID"], filter: [{ field: "timeEntryID", op: "in", value: chunk }] },
8946
+ operation: op
8947
+ });
8948
+ const { items, pageDetails } = unwrapItems(res);
8949
+ const billed = new Set(items.map((i) => i.timeEntryID));
8950
+ const more = pageDetails.nextPageUrl != null;
8951
+ if (more) incomplete = true;
8952
+ for (const id of chunk) {
8953
+ if (billed.has(id)) states.set(id, "posted");
8954
+ else if (!more) states.set(id, "unposted");
8955
+ }
8956
+ }
8957
+ return { states, incomplete };
8958
+ }
8959
+
8960
+ // src/resources/special/time_entries/windows.ts
8961
+ var DAY_MS = 864e5;
8962
+ var PAGE = 500;
8963
+ var COUNT_BUDGET_DEFAULT = 30;
8964
+ var COUNT_BUDGET_MAX = 200;
8965
+ var CURSOR_MAX = 2048;
8966
+ var YMD_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
8967
+ function ymdDayMs(value, label, OP7) {
8968
+ const m = YMD_RE.exec(value.trim());
8969
+ if (!m) {
8970
+ throw new AutotaskConfigError(`${OP7}: ${label} must be a UTC 'YYYY-MM-DD' date (got ${JSON.stringify(value)})`, { operation: OP7 });
8971
+ }
8972
+ const y = Number(m[1]);
8973
+ const mo = Number(m[2]);
8974
+ const d = Number(m[3]);
8975
+ const probe = new Date(Date.UTC(y, mo - 1, d));
8976
+ if (probe.getUTCFullYear() !== y || probe.getUTCMonth() !== mo - 1 || probe.getUTCDate() !== d) {
8977
+ throw new AutotaskConfigError(`${OP7}: ${label} is not a valid UTC date (${JSON.stringify(value)})`, { operation: OP7 });
8978
+ }
8979
+ return Date.UTC(y, mo - 1, d);
8980
+ }
8981
+ function windowBounds(from, to, OP7) {
8982
+ const fromMs = ymdDayMs(from, "from", OP7);
8983
+ const toMs = ymdDayMs(to, "to", OP7);
8984
+ if (fromMs > toMs) {
8985
+ throw new AutotaskConfigError(`${OP7}: from (${from}) must not be after to (${to})`, { operation: OP7 });
8986
+ }
8987
+ return { fromMs, toMs };
8988
+ }
8989
+ var iso = (ms) => new Date(ms).toISOString().slice(0, 19) + "Z";
8990
+ var ENTRY_CURSOR_PATH = "/V1.0/TimeEntries/query/next";
8991
+ function entryCursorPath(baseUrl, cursor) {
8992
+ const path = cursorToPath(baseUrl, cursor);
8993
+ if (path.split("?")[0] !== ENTRY_CURSOR_PATH) {
8994
+ throw new ResponseRejectedError(`Refused the pagination cursor: not a TimeEntries continuation (${ENTRY_CURSOR_PATH})`);
8995
+ }
8996
+ return path;
8997
+ }
8998
+ async function queryTimeEntries(_resource, filter, limit, operation, http, cursor) {
8999
+ const res = await http.request({
9000
+ method: "POST",
9001
+ idempotent: true,
9002
+ path: cursor === void 0 ? "/V1.0/TimeEntries/query" : entryCursorPath(http.baseUrl, cursor),
9003
+ body: { MaxRecords: limit, filter },
9004
+ operation
9005
+ });
9006
+ const { items, pageDetails } = unwrapItems(res);
9007
+ return { items, hasMore: pageDetails.nextPageUrl != null, nextCursor: pageDetails.nextPageUrl ?? null };
9008
+ }
9009
+ function pagingOpts(opts, OP7) {
9010
+ if (opts?.countOnly !== void 0 && typeof opts.countOnly !== "boolean") {
9011
+ throw new AutotaskConfigError(`${OP7}: countOnly must be a boolean`, { operation: OP7 });
9012
+ }
9013
+ const countOnly = opts?.countOnly === true;
9014
+ const cursor = opts?.cursor;
9015
+ if (cursor !== void 0 && (typeof cursor !== "string" || cursor.trim() === "")) {
9016
+ throw new AutotaskConfigError(`${OP7}: cursor must be the non-empty nextCursor of a previous call`, { operation: OP7 });
9017
+ }
9018
+ if (cursor !== void 0 && cursor.length > CURSOR_MAX) {
9019
+ throw new ResponseRejectedError(`${OP7}: refused the pagination cursor: ${cursor.length} chars is over the ${CURSOR_MAX}-char cap (a vendor nextPageUrl is far shorter)`);
9020
+ }
9021
+ if (cursor !== void 0 && countOnly) {
9022
+ throw new AutotaskConfigError(`${OP7}: cursor does not apply with countOnly (countOnly reads every page itself)`, { operation: OP7 });
9023
+ }
9024
+ const budget = opts?.maxVendorCalls ?? COUNT_BUDGET_DEFAULT;
9025
+ if (!Number.isInteger(budget) || budget < 1 || budget > COUNT_BUDGET_MAX) {
9026
+ throw new AutotaskConfigError(`${OP7}: maxVendorCalls must be an integer 1-${COUNT_BUDGET_MAX} (got ${String(opts?.maxVendorCalls)})`, { operation: OP7 });
9027
+ }
9028
+ return { ...cursor === void 0 ? {} : { cursor }, countOnly, budget };
9029
+ }
9030
+ function budgetExceeded(OP7, details, midWalk) {
9031
+ const { estimate, budget, total } = details;
9032
+ const raise = midWalk ? budget < COUNT_BUDGET_MAX ? `, or pass a maxVendorCalls above ${budget} for headroom past the estimate` : "" : estimate <= COUNT_BUDGET_MAX ? ` or pass maxVendorCalls >= ${estimate}` : "";
9033
+ return budgetExceededError(
9034
+ OP7,
9035
+ midWalk ? `${OP7}: countOnly stopped at the ${budget}-call budget mid-walk: the walk needed more calls than the ${estimate} estimated from the ${total}-entry count (more entries than counted, or an extra page such as an empty continuation page), so the next call, which may be the closing re-count, did not fit; no partial total is returned` : `${OP7}: countOnly needs an estimated ${estimate} vendor calls for ${total} entries (1 count + 1 per ${PAGE} entries + 1 join per 250 + 1 re-count), over the ${budget}-call budget; nothing was read past the count`,
9036
+ details,
9037
+ `Narrow the dateWorked window (from/to)${raise}.`
9038
+ );
9039
+ }
9040
+ async function countPostedState(http, filter, OP7, budget, window) {
9041
+ let estimate = 0;
9042
+ let counted = 0;
9043
+ const callBudget = makeCallBudget(budget, (spent) => budgetExceeded(OP7, { estimate, budget, total: counted, issued: spent }, true));
9044
+ const spend = callBudget.spend;
9045
+ spend();
9046
+ counted = unwrapQueryCount(
9047
+ await http.request({ method: "POST", idempotent: true, path: "/V1.0/TimeEntries/query/count", body: { filter }, operation: OP7 }),
9048
+ OP7
9049
+ );
9050
+ estimate = 1 + Math.max(1, Math.ceil(counted / PAGE)) + Math.ceil(counted / 250) + 1;
9051
+ if (estimate > budget) throw budgetExceeded(OP7, { estimate, budget, total: counted, issued: callBudget.spent() }, false);
9052
+ const states = /* @__PURE__ */ new Map();
9053
+ let cursor = null;
9054
+ do {
9055
+ spend();
9056
+ const res = await http.request({
9057
+ method: "POST",
9058
+ idempotent: true,
9059
+ path: cursor === null ? "/V1.0/TimeEntries/query" : entryCursorPath(http.baseUrl, cursor),
9060
+ body: { MaxRecords: PAGE, IncludeFields: ["id"], filter },
9061
+ operation: OP7
9062
+ });
9063
+ const page = unwrapItems(res);
9064
+ const joined = await postedStateOf(http, page.items, OP7, spend);
9065
+ if (joined.incomplete) {
9066
+ throw new AutotaskError(`${OP7}: countOnly refused: a billing-item join page was truncated (more than 500 billing items for 250 entries), so the posted split cannot be confirmed`, {
9067
+ code: "RESOLUTION_TRUNCATED",
9068
+ category: "resolution",
9069
+ operation: OP7,
9070
+ details: { issued: callBudget.spent(), budget },
9071
+ suggestedAction: "Narrow the dateWorked window, or page it with cursor (rows mode reports unconfirmed entries via truncated)."
9072
+ });
9073
+ }
9074
+ for (const [id, state] of joined.states) states.set(id, state);
9075
+ cursor = page.items.length === 0 ? null : page.pageDetails.nextPageUrl ?? null;
9076
+ } while (cursor !== null);
9077
+ spend();
9078
+ const recounted = unwrapQueryCount(
9079
+ await http.request({ method: "POST", idempotent: true, path: "/V1.0/TimeEntries/query/count", body: { filter }, operation: OP7 }),
9080
+ OP7
9081
+ );
9082
+ if (states.size !== counted && states.size !== recounted) {
9083
+ throw new AutotaskError(`${OP7}: countOnly read ${states.size} distinct entries but the window counted ${counted} before the walk and ${recounted} after it (a page repeated or dropped entries, or the window changed mid-walk); no total is returned. Retry the call`, {
9084
+ code: "INVALID_RESPONSE",
9085
+ category: "server",
9086
+ operation: OP7,
9087
+ details: { reason: "count_mismatch", streamed: states.size, counted, recounted, issued: callBudget.spent(), budget },
9088
+ suggestedAction: "Retry the call; a window that changes during the walk can fail or pass depending on timing."
9089
+ });
9090
+ }
9091
+ let postedCount = 0;
9092
+ for (const state of states.values()) if (state === "posted") postedCount += 1;
9093
+ return { window, total: states.size, postedCount, unpostedCount: states.size - postedCount, vendorCalls: callBudget.spent(), estimate, budget };
9094
+ }
9095
+ async function dateWorkedWindow(resource, from, to, opts) {
9096
+ const http = resource.http;
9097
+ const OP7 = "time_entries.dateWorkedWindow";
9098
+ const paging = pagingOpts(opts, OP7);
9099
+ const { fromMs, toMs } = windowBounds(from, to, OP7);
9100
+ const windowToMs = toMs + DAY_MS - 1;
9101
+ const filter = [
9102
+ { field: "dateWorked", op: "gte", value: iso(fromMs) },
9103
+ { field: "dateWorked", op: "lte", value: iso(windowToMs) }
9104
+ ];
9105
+ if (opts?.ticketID !== void 0) filter.push({ field: "ticketID", op: "eq", value: opts.ticketID });
9106
+ if (opts?.resourceID !== void 0) filter.push({ field: "resourceID", op: "eq", value: opts.resourceID });
9107
+ const window = { from: iso(fromMs), to: iso(windowToMs) };
9108
+ if (paging.countOnly) return countPostedState(http, filter, OP7, paging.budget, window);
9109
+ const { items, hasMore, nextCursor } = await queryTimeEntries(resource, filter, clampWindowLimit(opts?.limit, OP7), OP7, http, paging.cursor);
9110
+ const { states, incomplete } = await postedStateOf(http, items, OP7);
9111
+ let postedCount = 0;
9112
+ let unpostedCount = 0;
9113
+ for (const state of states.values()) {
9114
+ if (state === "posted") postedCount += 1;
9115
+ else unpostedCount += 1;
9116
+ }
9117
+ return {
9118
+ items: items.filter((e) => states.has(e.id) && (opts?.state === void 0 || states.get(e.id) === opts.state)),
9119
+ postedCount,
9120
+ unpostedCount,
9121
+ window,
9122
+ nextCursor,
9123
+ unconfirmed: items.filter((e) => !states.has(e.id)).length,
9124
+ // A live next-page cursor past the bounded read: the counts above cover
9125
+ // the returned rows ONLY when this is true (issue #66 AC1 — the 500-row cap case is the general case).
9126
+ truncated: hasMore || incomplete
9127
+ };
9128
+ }
9129
+ function clampWindowLimit(limit, OP7) {
9130
+ const n = limit === void 0 ? PAGE : Math.trunc(limit);
9131
+ if (!Number.isInteger(n) || n < 1) {
9132
+ throw new AutotaskConfigError(`limit must be an integer >= 1 (got ${String(limit)})`, { operation: OP7 });
9133
+ }
9134
+ return Math.min(n, PAGE);
9135
+ }
9136
+
9137
+ // src/resources/time_entries.ts
9138
+ var TimeEntriesResource = class extends BaseResource {
9139
+ constructor(http, pageSize) {
9140
+ super(http, {
9141
+ key: "time_entries",
9142
+ resourcePath: "/V1.0/TimeEntries",
9143
+ deletable: true,
9144
+ udfCapable: true,
9145
+ collectionUpdate: true
9146
+ // D1: the spec's item path is GET-only — confirmed updates PUT the collection (id in the body)
9147
+ }, pageSize);
9148
+ }
9149
+ /** Query TimeEntry records with the G3 filter surface (server-side JSON filter). */
9150
+ query(params) {
9151
+ return this.queryItems(params);
9152
+ }
9153
+ /** Get every TimeEntry record (the MCP-preferred read; 1000-page guarded). */
9154
+ async queryAll(params) {
9155
+ return this.queryAllItems(params);
9156
+ }
9157
+ /** Count matching records (queryCount envelope unwrapped to a plain number). */
9158
+ async count(params) {
9159
+ return this.countGlobal(params);
9160
+ }
9161
+ /** Get one record by id (item envelope; item: null → NotFoundError). `fresh: true` bypasses the read-cache lookup and refreshes the entry (issue #250). */
9162
+ async get(id, opts = {}) {
9163
+ return this.getGlobal(id, opts?.fresh === true);
9164
+ }
9165
+ /** Create a record (OperationResultModel → { id}). `dryRun: true` returns the
9166
+ * pinned envelope (section 19.3) and never issues the create. A 2xx that is not the
9167
+ * specced envelope runs the plan-pinned marker recovery (issue #13 — one bounded read;
9168
+ * `recoverIdFromMarker: false` restores the pre-recovery behaviour). */
9169
+ async create(data, opts = {}) {
9170
+ if (opts.dryRun === true) return this.runCreateDryRun(data);
9171
+ return this.createGlobal(data, opts);
9172
+ }
9173
+ /** Update a record (PUT on the COLLECTION — the vendored spec's item path is GET-only; the vendor
9174
+ * keys the record by the id in the body). The vendor validates the FULL model on this route (V2), so a
9175
+ * partial body is merged into the CURRENT record first: one fresh GET (which also serves the
9176
+ * stale-object guard's read) then the collection PUT of the merged model — an explicit null in the
9177
+ * caller body sends null; an omitted field keeps the current value. `raw: true` in the opts bag sends
9178
+ * the body as-is (full model or intentional partial — the compatibility escape hatch; no merge read).
9179
+ * `dryRun: true` returns the pinned envelope whose `data` is the merged body (the exact confirmed
9180
+ * wire) with the best-effort diff, from that same single read; a caller-supplied lastModifiedDate that
9181
+ * mismatches the vendor's current value throws STALE_OBJECT before the mutation. */
9182
+ async update(id, data, opts = {}) {
9183
+ if (opts.dryRun === true) return this.runUpdateDryRun(id, data, opts);
9184
+ return this.updateGlobal(id, data, opts);
9185
+ }
9186
+ /** Delete a record (OperationResultModel → { id}). `dryRun: true` returns the pinned
9187
+ * envelope and never issues the delete. */
9188
+ async delete(id, opts = {}) {
9189
+ if (opts.dryRun === true) return this.runDeleteDryRun(id, opts);
9190
+ await this.guardStaleBeforeMutation(id, opts, "time_entries.delete");
9191
+ return this.deleteGlobal(id, opts);
9192
+ }
9193
+ /** Resolve a TimeEntry from a numeric id (or, where the vendor indexes a primary text field,
9194
+ * from a numeric id — this resource has no primary text field). A numeric id miss throws NOT_FOUND;
9195
+ * a bare value rejects with a config error (ids only);
9196
+ * non-match returns null; ambiguity throws RESOLUTION_AMBIGUOUS (candidate ids) and a
9197
+ * cap-tripped bounded client scan throws RESOLUTION_TRUNCATED (never a silent null).
9198
+ * The compact shape is the structural alias of the full record; `expand: true` is the
9199
+ * type-level escape hatch. */
9200
+ async resolve(identifier, opts = {}) {
9201
+ void opts;
9202
+ return this.resolveBase(identifier);
9203
+ }
9204
+ dateWorkedWindow(from, to, opts) {
9205
+ return dateWorkedWindow(this, from, to, opts);
9206
+ }
9207
+ /** Node-parity special op (hand-written in src/resources/special/time_entries/move.ts). */
9208
+ moveToTicket(sourceId, toTicketId, opts = {}) {
9209
+ return moveToTicket2(this, sourceId, toTicketId, opts);
9210
+ }
9211
+ };
9212
+
8786
9213
  // src/resources/time_entry_attachments.ts
8787
9214
  var TimeEntryAttachmentsResource = class extends BaseResource {
8788
9215
  constructor(http, pageSize) {
@@ -8846,7 +9273,8 @@ var TimeEntryAttachmentsResource = class extends BaseResource {
8846
9273
  };
8847
9274
 
8848
9275
  // src/resources/special/time_off_requests.ts
8849
- async function approve(_resource, id, opts = {}, http) {
9276
+ async function approve(_resource, id, opts = {}) {
9277
+ const http = _resource.http;
8850
9278
  const numericId = parseIdentifier(id, "id");
8851
9279
  if (opts.dryRun === true) {
8852
9280
  return buildDryRunResult({
@@ -8867,7 +9295,8 @@ async function approve(_resource, id, opts = {}, http) {
8867
9295
  });
8868
9296
  return unwrapOperation(res);
8869
9297
  }
8870
- async function reject(_resource, id, reason, opts = {}, http) {
9298
+ async function reject(_resource, id, reason, opts = {}) {
9299
+ const http = _resource.http;
8871
9300
  const numericId = parseIdentifier(id, "id");
8872
9301
  const text = reason.trim();
8873
9302
  if (text === "") {
@@ -8934,11 +9363,11 @@ var TimeOffRequestsResource = class extends BaseResource {
8934
9363
  }
8935
9364
  /** Node-parity special op (hand-written in src/resources/special/time_off_requests.ts). */
8936
9365
  approve(id, opts = {}) {
8937
- return approve(this, id, opts, this.http);
9366
+ return approve(this, id, opts);
8938
9367
  }
8939
9368
  /** Node-parity special op (hand-written in src/resources/special/time_off_requests.ts). */
8940
9369
  reject(id, reason, opts = {}) {
8941
- return reject(this, id, reason, opts, this.http);
9370
+ return reject(this, id, reason, opts);
8942
9371
  }
8943
9372
  };
8944
9373
 
@@ -9005,6 +9434,7 @@ var ZoneInformationResource = class {
9005
9434
  export {
9006
9435
  AppointmentsResource,
9007
9436
  AuthenticateResource,
9437
+ BillingCodesResource,
9008
9438
  BillingItemApprovalLevelsResource,
9009
9439
  ChangeRequestLinksResource,
9010
9440
  ChecklistLibrariesResource,
@@ -9084,6 +9514,7 @@ export {
9084
9514
  ResourceRoleQueuesResource,
9085
9515
  ResourceRolesResource,
9086
9516
  ResourceSkillsResource,
9517
+ ResourcesResource,
9087
9518
  ResourceTimeOffAdditionalResource,
9088
9519
  ResourceTimeOffApproversResource,
9089
9520
  ResourceTimeOffBalancesResource,
@@ -9124,6 +9555,7 @@ export {
9124
9555
  TicketSecondaryResourcesResource,
9125
9556
  TicketsResource,
9126
9557
  TicketWebhooksResource,
9558
+ TimeEntriesResource,
9127
9559
  TimeEntryAttachmentsResource,
9128
9560
  TimeOffRequestsResource,
9129
9561
  VersionInformationResource,