@firedrill-tools/hubspot 0.1.1
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.
- package/LICENSE +201 -0
- package/README.md +263 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/conformance.suite.json +20 -0
- package/firedrill/hubspot-deactivated-user.drill.json +53 -0
- package/firedrill/hubspot-denied.drill.json +74 -0
- package/firedrill/hubspot-fresh-actor.drill.json +128 -0
- package/firedrill/hubspot-invalid-auth.drill.json +433 -0
- package/firedrill/hubspot-mcp-aliases.drill.json +265 -0
- package/firedrill/hubspot-no-scopes.drill.json +433 -0
- package/firedrill/hubspot-owned-visibility.drill.json +113 -0
- package/firedrill/hubspot-rate-limited.drill.json +446 -0
- package/firedrill/hubspot-rest-flow.drill.json +963 -0
- package/firedrill/hubspot-scopes.drill.json +166 -0
- package/firedrill/hubspot-size-bounds.drill.json +156 -0
- package/firedrill/hubspot-write-committed-lost.drill.json +166 -0
- package/firedrill/hubspot-write-unavailable.drill.json +292 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/tools/hubspot/behavior.mjs +1318 -0
- package/firedrill/tools/hubspot/hubspot.tool.json +5524 -0
- package/firedrill/tools/hubspot/lib/bytes.mjs +32 -0
- package/firedrill/tools/hubspot/lib/json-depth.mjs +17 -0
- package/firedrill/tools/hubspot/lib/object-types.mjs +47 -0
- package/firedrill/tools/hubspot/lib/properties.mjs +275 -0
- package/firedrill/tools/hubspot/lib/search-matchers.mjs +85 -0
- package/firedrill/tools/hubspot/lib/search.mjs +301 -0
- package/firedrill/tools/hubspot/lib/state.mjs +248 -0
- package/firedrill/tools/hubspot/lib/wire.mjs +125 -0
- package/firedrill/world.json +3128 -0
- package/firedrill/write-committed-lost.scenario.json +11 -0
- package/firedrill/write-unavailable.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +64 -0
- package/starter.json +2540 -0
- package/test/conformance.mjs +1007 -0
|
@@ -0,0 +1,1318 @@
|
|
|
1
|
+
// Synthetic HubSpot CRM portal. Every operation computes from context.state: object ids come from the
|
|
2
|
+
// `meta/counters` row, timestamps from the virtual clock, visibility from the calling actor's owner row.
|
|
3
|
+
// Nothing here contacts HubSpot; no e-mail, sequence or workflow is ever triggered.
|
|
4
|
+
import { ALL_SCOPES, OBJECT_TYPES, objectTypeById, readScope, resolveObjectType, schemaReadScope, writeScope } from "./lib/object-types.mjs";
|
|
5
|
+
import { DEFAULT_PROPERTIES, DEFINED_PROPERTIES, REQUIRED_ON_CREATE, SEARCHABLE_PROPERTIES, TIMESTAMPS, coerceValue } from "./lib/properties.mjs";
|
|
6
|
+
import { RESPONSE_BYTE_BUDGET, PAGE_ENVELOPE_BYTES, jsonBytes } from "./lib/bytes.mjs";
|
|
7
|
+
import { MAX_MATCH_WORK, MAX_SEARCH_TEXT, createRecordMatcher, createWorkBudget, isMangled, normalizeFilterGroups, normalizeSorts, sortRecords } from "./lib/search.mjs";
|
|
8
|
+
import {
|
|
9
|
+
allRows,
|
|
10
|
+
associationPrefix,
|
|
11
|
+
clip,
|
|
12
|
+
clipJson,
|
|
13
|
+
associationRowId,
|
|
14
|
+
compareStrings,
|
|
15
|
+
fail,
|
|
16
|
+
findRow,
|
|
17
|
+
isObjectId,
|
|
18
|
+
isRowKey,
|
|
19
|
+
isoNow,
|
|
20
|
+
nextObjectId,
|
|
21
|
+
notFound,
|
|
22
|
+
padId,
|
|
23
|
+
prefixRows,
|
|
24
|
+
validationError,
|
|
25
|
+
} from "./lib/state.mjs";
|
|
26
|
+
import { bool, defined, hubspotError, int, isRateLimited, jsonArrayBody, jsonBody, list, operationInput, responseHeaders, str } from "./lib/wire.mjs";
|
|
27
|
+
|
|
28
|
+
const ACCOUNT = Object.freeze({
|
|
29
|
+
portalId: 24871365,
|
|
30
|
+
accountType: "STANDARD",
|
|
31
|
+
timeZone: "Europe/Stockholm",
|
|
32
|
+
companyCurrency: "EUR",
|
|
33
|
+
additionalCurrencies: [],
|
|
34
|
+
utcOffset: "+02:00",
|
|
35
|
+
utcOffsetMilliseconds: 7_200_000,
|
|
36
|
+
uiDomain: "app.hubspot.com",
|
|
37
|
+
dataHostingLocation: "eu1",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const OBJECT_NAMESPACES = OBJECT_TYPES.map((type) => type.name);
|
|
41
|
+
const UNAUTHORIZED_MESSAGE =
|
|
42
|
+
"Authentication credentials not found. This API supports OAuth 2.0 authentication and you can find more details at https://developers.hubspot.com/docs/methods/auth/oauth-overview";
|
|
43
|
+
const MISSING_SCOPES_MESSAGE =
|
|
44
|
+
"This app hasn't been granted all required scopes to make this call. Read more about required scopes here: https://developers.hubspot.com/scopes.";
|
|
45
|
+
const MAX_BATCH = 100;
|
|
46
|
+
const MAX_LIST_LIMIT = 100;
|
|
47
|
+
const MAX_ASSOCIATION_LIMIT = 500;
|
|
48
|
+
const MAX_OWNER_LIMIT = 500;
|
|
49
|
+
const MAX_SEARCH_LIMIT = 200;
|
|
50
|
+
const MAX_SEARCH_OFFSET = 10_000;
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------------------------
|
|
53
|
+
// Identity, scopes and visibility
|
|
54
|
+
// ---------------------------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
function unauthorized(context) {
|
|
57
|
+
return fail(context, "UNAUTHORIZED", UNAUTHORIZED_MESSAGE);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The caller. With an `email` attribute: the non-archived owner with that e-mail (none → UNAUTHORIZED).
|
|
62
|
+
* Without one (e.g. an actor created by `firedrill tool add`): the portal's default user, i.e. the first
|
|
63
|
+
* non-archived owner in row-id order (41001 in the starter data); a portal without owners → UNAUTHORIZED.
|
|
64
|
+
*/
|
|
65
|
+
function requireUser(context) {
|
|
66
|
+
const attributes = context.actor.attributes ?? {};
|
|
67
|
+
const email = typeof attributes.email === "string" ? attributes.email.trim().toLowerCase() : "";
|
|
68
|
+
const owner =
|
|
69
|
+
email.length === 0
|
|
70
|
+
? findRow(context, "owners", (row) => row.archived !== true)
|
|
71
|
+
: findRow(context, "owners", (row) => typeof row.email === "string" && row.email.toLowerCase() === email && row.archived !== true);
|
|
72
|
+
if (owner === null) return unauthorized(context);
|
|
73
|
+
const scopes = Array.isArray(attributes.scopes) ? attributes.scopes.filter((scope) => typeof scope === "string") : null;
|
|
74
|
+
const recordAccess = attributes.recordAccess === "owned" ? "owned" : "all";
|
|
75
|
+
return { owner, scopes, recordAccess };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function requireScopes(context, user, ...scopes) {
|
|
79
|
+
if (user.scopes === null) return;
|
|
80
|
+
const missing = scopes.filter((scope) => !user.scopes.includes(scope));
|
|
81
|
+
if (missing.length === 0) return;
|
|
82
|
+
return fail(context, "MISSING_SCOPES", MISSING_SCOPES_MESSAGE, {
|
|
83
|
+
errors: [{ message: "One or more of the following scopes are required.", context: { requiredGranularScopes: missing } }],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function requireType(context, value) {
|
|
88
|
+
const type = resolveObjectType(value);
|
|
89
|
+
if (type === undefined) return notFound(context, `Unable to infer object type from: ${clip(typeof value === "string" ? value : JSON.stringify(value ?? null))}`);
|
|
90
|
+
return type;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** "Owned records only": a record is visible when unassigned or owned by the caller. */
|
|
94
|
+
function isVisible(user, record) {
|
|
95
|
+
if (user.recordAccess !== "owned") return true;
|
|
96
|
+
const owner = record.properties.hubspot_owner_id;
|
|
97
|
+
return owner === null || owner === undefined || owner === "" || owner === user.owner.id;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------------------------
|
|
101
|
+
// Records
|
|
102
|
+
// ---------------------------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** HubSpot-defined definitions plus the custom `properties` rows of the type (custom rows cannot shadow). */
|
|
105
|
+
function definitionsFor(context, type) {
|
|
106
|
+
const map = new Map(DEFINED_PROPERTIES[type.name]);
|
|
107
|
+
for (const row of prefixRows(context, "properties", `${type.name}/`)) if (!map.has(row.name)) map.set(row.name, row);
|
|
108
|
+
return map;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Load a record by id; archived or hidden records count as missing unless `includeArchived`. */
|
|
112
|
+
function loadRecord(context, user, type, id, includeArchived = false) {
|
|
113
|
+
if (!isObjectId(id)) return null;
|
|
114
|
+
const record = context.state.get(type.name, padId(id));
|
|
115
|
+
if (record === null) return null;
|
|
116
|
+
if (record.archived && !includeArchived) return null;
|
|
117
|
+
if (!isVisible(user, record)) return null;
|
|
118
|
+
return record;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Per-request lookups of unique properties (`idProperty` resolution and contact e-mail uniqueness). Each index is
|
|
123
|
+
* built with one bounded scan the first time a property is used and kept current as the request writes records, so
|
|
124
|
+
* a batch of N entries costs one scan instead of N (lower-cased value → records in row-id order).
|
|
125
|
+
*/
|
|
126
|
+
function uniqueLookups(context, type) {
|
|
127
|
+
const indexes = new Map();
|
|
128
|
+
const keyOf = (record, name) => {
|
|
129
|
+
const value = Object.hasOwn(record.properties, name) ? record.properties[name] : undefined;
|
|
130
|
+
return typeof value === "string" ? value.toLowerCase() : undefined;
|
|
131
|
+
};
|
|
132
|
+
const insert = (index, name, record) => {
|
|
133
|
+
const key = keyOf(record, name);
|
|
134
|
+
if (key === undefined) return;
|
|
135
|
+
const list = index.get(key);
|
|
136
|
+
if (list === undefined) return void index.set(key, [record]);
|
|
137
|
+
const at = list.findIndex((other) => Number(other.id) > Number(record.id));
|
|
138
|
+
if (at === -1) list.push(record);
|
|
139
|
+
else list.splice(at, 0, record);
|
|
140
|
+
};
|
|
141
|
+
const remove = (index, name, record) => {
|
|
142
|
+
const key = keyOf(record, name);
|
|
143
|
+
const list = key === undefined ? undefined : index.get(key);
|
|
144
|
+
const at = list === undefined ? -1 : list.findIndex((other) => other.id === record.id);
|
|
145
|
+
if (at !== -1) list.splice(at, 1);
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
candidates(name, value) {
|
|
149
|
+
let index = indexes.get(name);
|
|
150
|
+
if (index === undefined) {
|
|
151
|
+
index = new Map();
|
|
152
|
+
for (const record of allRows(context, type.name)) insert(index, name, record);
|
|
153
|
+
indexes.set(name, index);
|
|
154
|
+
}
|
|
155
|
+
return index.get(String(value).toLowerCase()) ?? [];
|
|
156
|
+
},
|
|
157
|
+
replace(before, after) {
|
|
158
|
+
for (const [name, index] of indexes) {
|
|
159
|
+
if (before !== null) remove(index, name, before);
|
|
160
|
+
if (after !== null) insert(index, name, after);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function findByUniqueProperty(context, user, type, definitions, propertyName, value, includeArchived, lookups) {
|
|
167
|
+
const definition = definitions.get(propertyName);
|
|
168
|
+
if (definition === undefined || definition.hasUniqueValue !== true) {
|
|
169
|
+
return validationError(context, `Invalid idProperty: ${clip(propertyName)}`, [{ message: `Property "${clip(propertyName)}" is not a unique identifier of ${type.name}`, context: { propertyName: [clip(propertyName)] } }]);
|
|
170
|
+
}
|
|
171
|
+
const candidates = (lookups ?? uniqueLookups(context, type)).candidates(propertyName, value);
|
|
172
|
+
return candidates.find((record) => (includeArchived || !record.archived) && isVisible(user, record)) ?? null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function resolveRecord(context, user, type, definitions, id, idProperty, includeArchived = false, lookups = undefined) {
|
|
176
|
+
if (idProperty !== undefined && idProperty !== "id" && idProperty !== "hs_object_id") {
|
|
177
|
+
return findByUniqueProperty(context, user, type, definitions, idProperty, id, includeArchived, lookups);
|
|
178
|
+
}
|
|
179
|
+
return loadRecord(context, user, type, String(id), includeArchived);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function requireRecord(context, user, type, definitions, id, idProperty, includeArchived = false, lookups = undefined) {
|
|
183
|
+
const record = resolveRecord(context, user, type, definitions, id, idProperty, includeArchived, lookups);
|
|
184
|
+
return record === null ? notFound(context) : record;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function saveRecord(context, type, record) {
|
|
188
|
+
context.state.put(type.name, padId(record.id), record);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The property names of a read: requested (unknown names silently dropped) or the default set, plus the always-present system properties. */
|
|
192
|
+
function readNames(type, requested, definitions) {
|
|
193
|
+
const base = Array.isArray(requested) && requested.length > 0 ? requested : DEFAULT_PROPERTIES[type.name];
|
|
194
|
+
const names = new Set(["hs_object_id", TIMESTAMPS[type.name].created, TIMESTAMPS[type.name].modified]);
|
|
195
|
+
for (const name of base) if (typeof name === "string" && definitions.has(name)) names.add(name);
|
|
196
|
+
return [...names].sort(compareStrings);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Every stored non-null property plus the default set (the shape of create/update responses). */
|
|
200
|
+
function writeNames(type, record, definitions) {
|
|
201
|
+
const names = new Set(DEFAULT_PROPERTIES[type.name]);
|
|
202
|
+
for (const [name, value] of Object.entries(record.properties)) if (value !== null) names.add(name);
|
|
203
|
+
return readNames(type, [...names], definitions);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function recordView(record, names, associations) {
|
|
207
|
+
const properties = {};
|
|
208
|
+
for (const name of names) properties[name] = record.properties[name] ?? null;
|
|
209
|
+
const view = { id: record.id, properties, createdAt: record.createdAt, updatedAt: record.updatedAt, archived: record.archived };
|
|
210
|
+
if (record.archived) view.archivedAt = record.archivedAt;
|
|
211
|
+
if (associations !== undefined) view.associations = associations;
|
|
212
|
+
return view;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function requireOwner(context, id) {
|
|
216
|
+
if (!isObjectId(id)) return null;
|
|
217
|
+
const owner = context.state.get("owners", padId(id));
|
|
218
|
+
return owner !== null && owner.archived !== true ? owner : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function propertyError(name, code, message) {
|
|
222
|
+
return { isValid: false, message, error: code, name };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function raisePropertyErrors(context, errors) {
|
|
226
|
+
return validationError(
|
|
227
|
+
context,
|
|
228
|
+
`Property values were not valid: ${JSON.stringify(errors)}`,
|
|
229
|
+
errors.map((error) => ({ message: error.message, code: error.error, context: { propertyName: [error.name] } })),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Validate and merge incoming property values (create when `existing` is null; PATCH merge otherwise).
|
|
235
|
+
* Returns `{ properties, changes }` where `changes` lists every property whose stored value differs.
|
|
236
|
+
*/
|
|
237
|
+
function mergeProperties(context, user, type, definitions, incoming, existing, now, lookups = undefined) {
|
|
238
|
+
if (typeof incoming !== "object" || incoming === null || Array.isArray(incoming) || Object.keys(incoming).length === 0) {
|
|
239
|
+
return validationError(context, "properties must be a non-empty object", [{ message: "properties must be a non-empty object", context: { propertyName: ["properties"] } }]);
|
|
240
|
+
}
|
|
241
|
+
const errors = [];
|
|
242
|
+
const next = existing === null ? {} : { ...existing.properties };
|
|
243
|
+
for (const [name, raw] of Object.entries(incoming)) {
|
|
244
|
+
const definition = definitions.get(name);
|
|
245
|
+
if (definition === undefined) {
|
|
246
|
+
errors.push(propertyError(clip(name), "PROPERTY_DOESNT_EXIST", `Property "${clip(name)}" does not exist`));
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (definition.modificationMetadata.readOnlyValue) {
|
|
250
|
+
errors.push(propertyError(name, "READ_ONLY_VALUE", `Property "${name}" is read only`));
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const coerced = coerceValue(definition, raw);
|
|
254
|
+
if (coerced.error !== undefined) {
|
|
255
|
+
errors.push(propertyError(name, coerced.code, coerced.error));
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (name === "hubspot_owner_id" && coerced.value !== null && requireOwner(context, coerced.value) === null) {
|
|
259
|
+
errors.push(propertyError(name, "INVALID_OWNER_ID", `Owner ${clip(coerced.value)} is not valid`));
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
// On create a null value sets nothing (there is nothing to clear); on PATCH it clears the property.
|
|
263
|
+
if (coerced.value === null && existing === null) continue;
|
|
264
|
+
next[name] = coerced.value;
|
|
265
|
+
}
|
|
266
|
+
if (existing === null) {
|
|
267
|
+
for (const name of REQUIRED_ON_CREATE[type.name]) {
|
|
268
|
+
if (errors.some((error) => error.name === name)) continue;
|
|
269
|
+
if (next[name] === undefined || next[name] === null || next[name] === "") {
|
|
270
|
+
errors.push(propertyError(name, "REQUIRED_FIELD_NOT_SET", `Property "${name}" is required to create a ${type.singular}`));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (type.name === "deals" && errors.length === 0) {
|
|
275
|
+
const pipelineId = next.pipeline ?? "default";
|
|
276
|
+
const pipeline = pipelineRow(context, pipelineId);
|
|
277
|
+
if (pipeline === null || pipeline.archived) {
|
|
278
|
+
errors.push(propertyError("pipeline", "INVALID_OPTION", `Pipeline ID ${clip(pipelineId)} is not valid`));
|
|
279
|
+
} else {
|
|
280
|
+
next.pipeline = pipelineId;
|
|
281
|
+
const stage = pipeline.stages.find((candidate) => candidate.id === next.dealstage && !candidate.archived);
|
|
282
|
+
if (stage === undefined) {
|
|
283
|
+
errors.push(propertyError("dealstage", "INVALID_OPTION", `Pipeline stage ID ${clip(next.dealstage)} is not valid for pipeline ${clip(pipelineId)}`));
|
|
284
|
+
} else {
|
|
285
|
+
next.hs_deal_stage_probability = stage.metadata.probability;
|
|
286
|
+
next.hs_is_closed = stage.metadata.isClosed;
|
|
287
|
+
next.hs_is_closed_won = stage.metadata.isClosed === "true" && Number(stage.metadata.probability) >= 1 ? "true" : "false";
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (errors.length > 0) return raisePropertyErrors(context, errors);
|
|
292
|
+
if (type.name === "contacts" && typeof next.email === "string" && next.email.length > 0 && (existing === null || existing.properties.email !== next.email)) {
|
|
293
|
+
const duplicate = (lookups ?? uniqueLookups(context, type))
|
|
294
|
+
.candidates("email", next.email)
|
|
295
|
+
.find((record) => !record.archived && (existing === null || record.id !== existing.id));
|
|
296
|
+
if (duplicate !== undefined) {
|
|
297
|
+
return fail(context, "CONFLICT", `Contact already exists. Existing ID: ${duplicate.id}`, { context: { existingId: [duplicate.id] } });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (type.name === "contacts" && incoming.hubspot_owner_id !== undefined && (existing === null || existing.properties.hubspot_owner_id !== next.hubspot_owner_id)) {
|
|
301
|
+
const assigned = next.hubspot_owner_id !== null && next.hubspot_owner_id !== undefined;
|
|
302
|
+
if (assigned) next.hubspot_owner_assigneddate = now;
|
|
303
|
+
else if (existing !== null) next.hubspot_owner_assigneddate = null;
|
|
304
|
+
}
|
|
305
|
+
const changes = [];
|
|
306
|
+
if (existing !== null) {
|
|
307
|
+
for (const name of Object.keys(next)) {
|
|
308
|
+
const previous = existing.properties[name] ?? null;
|
|
309
|
+
const current = next[name] ?? null;
|
|
310
|
+
if (previous !== current) changes.push({ name, previous, current });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { properties: next, changes };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function emitCreated(context, type, record) {
|
|
317
|
+
context.events.emit("object.created", {
|
|
318
|
+
objectType: type.name,
|
|
319
|
+
objectTypeId: type.typeId,
|
|
320
|
+
objectId: record.id,
|
|
321
|
+
ownerId: record.properties.hubspot_owner_id ?? null,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function emitChanges(context, type, record, changes) {
|
|
326
|
+
for (const change of changes) {
|
|
327
|
+
context.events.emit("object.property-changed", {
|
|
328
|
+
objectType: type.name,
|
|
329
|
+
objectTypeId: type.typeId,
|
|
330
|
+
objectId: record.id,
|
|
331
|
+
propertyName: change.name,
|
|
332
|
+
propertyValue: change.current,
|
|
333
|
+
previousValue: change.previous,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Create one record (the property map has been validated). Returns the stored row. */
|
|
339
|
+
function createRecord(context, type, properties, now) {
|
|
340
|
+
const id = nextObjectId(context, OBJECT_NAMESPACES);
|
|
341
|
+
const stamps = TIMESTAMPS[type.name];
|
|
342
|
+
const record = {
|
|
343
|
+
id,
|
|
344
|
+
properties: { ...properties, hs_object_id: id, [stamps.created]: now, [stamps.modified]: now },
|
|
345
|
+
createdAt: now,
|
|
346
|
+
updatedAt: now,
|
|
347
|
+
archived: false,
|
|
348
|
+
archivedAt: null,
|
|
349
|
+
};
|
|
350
|
+
saveRecord(context, type, record);
|
|
351
|
+
emitCreated(context, type, record);
|
|
352
|
+
return record;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function updateRecord(context, type, existing, merged, now) {
|
|
356
|
+
const stamps = TIMESTAMPS[type.name];
|
|
357
|
+
const record = { ...existing, properties: { ...merged.properties, [stamps.modified]: now }, updatedAt: now };
|
|
358
|
+
saveRecord(context, type, record);
|
|
359
|
+
emitChanges(context, type, record, merged.changes);
|
|
360
|
+
return record;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Contact e-mails inside one batch must be unique as well (case-insensitive). */
|
|
364
|
+
function requireUniqueEmails(context, type, propertyMaps) {
|
|
365
|
+
if (type.name !== "contacts") return;
|
|
366
|
+
const seen = new Map();
|
|
367
|
+
for (const properties of propertyMaps) {
|
|
368
|
+
const email = typeof properties.email === "string" ? properties.email.toLowerCase() : "";
|
|
369
|
+
if (email.length === 0) continue;
|
|
370
|
+
if (seen.has(email)) return fail(context, "CONFLICT", `Contact already exists. Existing ID: ${seen.get(email)}`, { context: { email: [email] } });
|
|
371
|
+
seen.set(email, "pending");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------------------------
|
|
376
|
+
// Associations
|
|
377
|
+
// ---------------------------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
/** Association definitions for a type pair keyed by typeId. */
|
|
380
|
+
function labelDefinitions(context, fromType, toType) {
|
|
381
|
+
const map = new Map();
|
|
382
|
+
for (const row of prefixRows(context, "association-labels", `${fromType.typeId}/${toType.typeId}/`)) map.set(row.typeId, row);
|
|
383
|
+
return map;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function labelView(definition) {
|
|
387
|
+
return { category: definition.category, typeId: definition.typeId, label: definition.label };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function linkRow(context, fromType, fromId, toType, toId) {
|
|
391
|
+
return context.state.get("associations", associationRowId(fromType.typeId, fromId, toType.typeId, toId));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function saveLink(context, row) {
|
|
395
|
+
const rowId = associationRowId(row.fromObjectTypeId, row.fromObjectId, row.toObjectTypeId, row.toObjectId);
|
|
396
|
+
if (row.typeIds.length === 0) context.state.delete("associations", rowId);
|
|
397
|
+
else context.state.put("associations", rowId, row);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function emitAssociation(context, row, typeIds, removed) {
|
|
401
|
+
context.events.emit("association.changed", {
|
|
402
|
+
fromObjectTypeId: row.fromObjectTypeId,
|
|
403
|
+
fromObjectId: row.fromObjectId,
|
|
404
|
+
toObjectTypeId: row.toObjectTypeId,
|
|
405
|
+
toObjectId: row.toObjectId,
|
|
406
|
+
associationTypeIds: typeIds,
|
|
407
|
+
removed,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Add type ids to one directed row (creating it when absent); returns the ids actually added. */
|
|
412
|
+
function addTypes(context, fromTypeId, fromId, toTypeId, toId, typeIds, now) {
|
|
413
|
+
const rowId = associationRowId(fromTypeId, fromId, toTypeId, toId);
|
|
414
|
+
const existing = context.state.get("associations", rowId);
|
|
415
|
+
const current = existing === null ? [] : existing.typeIds;
|
|
416
|
+
const added = typeIds.filter((typeId) => !current.includes(typeId));
|
|
417
|
+
if (added.length === 0) return [];
|
|
418
|
+
const row = {
|
|
419
|
+
fromObjectTypeId: fromTypeId,
|
|
420
|
+
fromObjectId: fromId,
|
|
421
|
+
toObjectTypeId: toTypeId,
|
|
422
|
+
toObjectId: toId,
|
|
423
|
+
typeIds: [...current, ...added].sort((left, right) => left - right),
|
|
424
|
+
createdAt: existing === null ? now : existing.createdAt,
|
|
425
|
+
updatedAt: now,
|
|
426
|
+
};
|
|
427
|
+
context.state.put("associations", rowId, row);
|
|
428
|
+
emitAssociation(context, row, added, false);
|
|
429
|
+
return added;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function removeTypes(context, row, typeIds) {
|
|
433
|
+
const removed = row.typeIds.filter((typeId) => typeIds.includes(typeId));
|
|
434
|
+
if (removed.length === 0) return;
|
|
435
|
+
const next = { ...row, typeIds: row.typeIds.filter((typeId) => !typeIds.includes(typeId)) };
|
|
436
|
+
saveLink(context, next);
|
|
437
|
+
emitAssociation(context, row, removed, true);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** A HubSpot-defined "Primary" label is exclusive: one primary target per record and type pair. */
|
|
441
|
+
function clearOtherPrimaries(context, fromType, fromId, toType, toId, definition) {
|
|
442
|
+
if (definition.category !== "HUBSPOT_DEFINED" || definition.label !== "Primary") return;
|
|
443
|
+
for (const row of prefixRows(context, "associations", associationPrefix(fromType.typeId, fromId, toType.typeId))) {
|
|
444
|
+
if (row.toObjectId === toId || !row.typeIds.includes(definition.typeId)) continue;
|
|
445
|
+
removeTypes(context, row, [definition.typeId]);
|
|
446
|
+
const inverse = context.state.get("associations", associationRowId(toType.typeId, row.toObjectId, fromType.typeId, fromId));
|
|
447
|
+
if (inverse !== null) removeTypes(context, inverse, [definition.inverseTypeId]);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Create/extend an association in both directions with validated definitions. */
|
|
452
|
+
function associate(context, fromType, fromId, toType, toId, definitions, now) {
|
|
453
|
+
for (const definition of definitions) clearOtherPrimaries(context, fromType, fromId, toType, toId, definition);
|
|
454
|
+
addTypes(context, fromType.typeId, fromId, toType.typeId, toId, definitions.map((definition) => definition.typeId), now);
|
|
455
|
+
addTypes(context, toType.typeId, toId, fromType.typeId, fromId, definitions.map((definition) => definition.inverseTypeId), now);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Validate `types: [{ associationCategory, associationTypeId }]` against the pair's definitions. */
|
|
459
|
+
function requireTypeSpecs(context, fromType, toType, specs) {
|
|
460
|
+
if (!Array.isArray(specs) || specs.length === 0) {
|
|
461
|
+
return validationError(context, "At least one association type is required", [{ message: "types must be a non-empty array", context: { propertyName: ["types"] } }]);
|
|
462
|
+
}
|
|
463
|
+
const definitions = labelDefinitions(context, fromType, toType);
|
|
464
|
+
const resolved = [];
|
|
465
|
+
for (const spec of specs) {
|
|
466
|
+
const typeId = typeof spec === "object" && spec !== null ? spec.associationTypeId : undefined;
|
|
467
|
+
const category = typeof spec === "object" && spec !== null ? spec.associationCategory : undefined;
|
|
468
|
+
const definition = typeof typeId === "number" ? definitions.get(typeId) : undefined;
|
|
469
|
+
if (definition === undefined || (category !== undefined && category !== definition.category)) {
|
|
470
|
+
return validationError(
|
|
471
|
+
context,
|
|
472
|
+
`Association type ${clipJson(typeId ?? null)} (${clip(category ?? "unspecified")}) is not defined between ${fromType.singular} and ${toType.singular}`,
|
|
473
|
+
[{ message: `Unknown association type ${clipJson(typeId ?? null)} for ${fromType.typeId} -> ${toType.typeId}`, context: { associationTypeId: [clip(typeId ?? null)] } }],
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
if (!resolved.some((entry) => entry.typeId === definition.typeId)) resolved.push(definition);
|
|
477
|
+
}
|
|
478
|
+
return resolved;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function defaultDefinition(context, fromType, toType) {
|
|
482
|
+
return [...labelDefinitions(context, fromType, toType).values()].find((definition) => definition.category === "HUBSPOT_DEFINED" && definition.label === null);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** The visible links of a record towards one type (targets that are archived or hidden are omitted). */
|
|
486
|
+
function visibleLinks(context, user, fromType, fromId, toType) {
|
|
487
|
+
const rows = prefixRows(context, "associations", associationPrefix(fromType.typeId, fromId, toType.typeId));
|
|
488
|
+
return rows.filter((row) => loadRecord(context, user, toType, row.toObjectId) !== null);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function associationTypeName(fromType, toType, definition) {
|
|
492
|
+
const base = `${fromType.singular}_to_${toType.singular}`;
|
|
493
|
+
return definition?.label ? `${base}_${definition.label.toLowerCase().replace(/[^a-z0-9]+/g, "_")}` : base;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function inlineAssociations(context, user, type, record, toTypeNames) {
|
|
497
|
+
if (!Array.isArray(toTypeNames) || toTypeNames.length === 0) return undefined;
|
|
498
|
+
const view = {};
|
|
499
|
+
const seen = new Set();
|
|
500
|
+
for (const name of toTypeNames) {
|
|
501
|
+
const toType = resolveObjectType(name);
|
|
502
|
+
if (toType === undefined || seen.has(toType.name)) continue;
|
|
503
|
+
seen.add(toType.name);
|
|
504
|
+
const definitions = labelDefinitions(context, type, toType);
|
|
505
|
+
const results = [];
|
|
506
|
+
for (const link of visibleLinks(context, user, type, record.id, toType)) {
|
|
507
|
+
for (const typeId of link.typeIds) results.push({ id: link.toObjectId, type: associationTypeName(type, toType, definitions.get(typeId)) });
|
|
508
|
+
}
|
|
509
|
+
if (results.length > 0) view[toType.name] = { results };
|
|
510
|
+
}
|
|
511
|
+
return Object.keys(view).length > 0 ? view : undefined;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function linkView(link, definitions) {
|
|
515
|
+
return {
|
|
516
|
+
toObjectId: link.toObjectId,
|
|
517
|
+
associationTypes: link.typeIds.map((typeId) => {
|
|
518
|
+
const definition = definitions.get(typeId);
|
|
519
|
+
return definition === undefined ? { category: "HUBSPOT_DEFINED", typeId, label: null } : labelView(definition);
|
|
520
|
+
}),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function labelsOf(link, definitions) {
|
|
525
|
+
return link === null ? [] : link.typeIds.map((typeId) => definitions.get(typeId)?.label ?? null).filter((label) => label !== null);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function associationResult(fromType, fromId, toType, toId, labels) {
|
|
529
|
+
return { fromObjectTypeId: fromType.typeId, fromObjectId: fromId, toObjectTypeId: toType.typeId, toObjectId: toId, labels };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Inline `associations` of a create body: the target type is implied by the association type id. */
|
|
533
|
+
function resolveInlineAssociations(context, user, fromType, specs) {
|
|
534
|
+
if (specs === undefined) return [];
|
|
535
|
+
if (!Array.isArray(specs)) return validationError(context, "associations must be an array");
|
|
536
|
+
const byTypeId = new Map();
|
|
537
|
+
for (const row of prefixRows(context, "association-labels", `${fromType.typeId}/`)) byTypeId.set(row.typeId, row);
|
|
538
|
+
const resolved = [];
|
|
539
|
+
for (const spec of specs) {
|
|
540
|
+
const toId = typeof spec?.to === "object" && spec.to !== null ? String(spec.to.id ?? "") : "";
|
|
541
|
+
const types = Array.isArray(spec?.types) ? spec.types : [];
|
|
542
|
+
if (types.length === 0) return validationError(context, "Each inline association needs at least one type");
|
|
543
|
+
for (const entry of types) {
|
|
544
|
+
const definition = typeof entry?.associationTypeId === "number" ? byTypeId.get(entry.associationTypeId) : undefined;
|
|
545
|
+
if (definition === undefined || (entry.associationCategory !== undefined && entry.associationCategory !== definition.category)) {
|
|
546
|
+
return validationError(context, `Association type ${clipJson(entry?.associationTypeId ?? null)} is not defined for ${fromType.singular} records`, [
|
|
547
|
+
{ message: "Unknown association type", context: { associationTypeId: [clip(entry?.associationTypeId ?? null)] } },
|
|
548
|
+
]);
|
|
549
|
+
}
|
|
550
|
+
const toType = objectTypeById(definition.toObjectTypeId);
|
|
551
|
+
if (loadRecord(context, user, toType, toId) === null) return notFound(context, `No ${toType.singular} with ID ${clip(toId)} exists`);
|
|
552
|
+
resolved.push({ toType, toId, definition });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return resolved;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function applyInlineAssociations(context, fromType, fromId, resolved, now) {
|
|
559
|
+
for (const { toType, toId, definition } of resolved) associate(context, fromType, fromId, toType, toId, [definition], now);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------------------------
|
|
563
|
+
// Batch helpers
|
|
564
|
+
// ---------------------------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
function requireInputs(context, inputs) {
|
|
567
|
+
if (!Array.isArray(inputs) || inputs.length === 0) return validationError(context, "inputs must be a non-empty array", [{ message: "inputs must be a non-empty array", context: { propertyName: ["inputs"] } }]);
|
|
568
|
+
if (inputs.length > MAX_BATCH) return validationError(context, `At most ${MAX_BATCH} inputs are allowed per batch request`, [{ message: `inputs has ${inputs.length} entries`, context: { propertyName: ["inputs"] } }]);
|
|
569
|
+
return inputs;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function batchEnvelope(results, errors, startedAt, completedAt) {
|
|
573
|
+
const body = { status: "COMPLETE", results, startedAt, completedAt };
|
|
574
|
+
if (errors.length > 0) {
|
|
575
|
+
body.numErrors = errors.length;
|
|
576
|
+
body.errors = errors;
|
|
577
|
+
}
|
|
578
|
+
return body;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function missingObjectsError(type, ids) {
|
|
582
|
+
return {
|
|
583
|
+
status: "error",
|
|
584
|
+
category: "OBJECT_NOT_FOUND",
|
|
585
|
+
message: `Could not get some ${type.singular.toUpperCase()} objects, they may be deleted or not exist. Check that ids are valid.`,
|
|
586
|
+
context: { ids },
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function missingRecordError(type, id) {
|
|
591
|
+
return { status: "error", category: "OBJECT_NOT_FOUND", message: `No ${type.singular} with ID ${id} exists`, context: { objectType: [type.singular], id: [id] } };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function rejectHistory(context, input) {
|
|
595
|
+
if (input.propertiesWithHistory !== undefined && input.propertiesWithHistory.length > 0) {
|
|
596
|
+
return validationError(context, "propertiesWithHistory is not supported by this Tool", [{ message: "Property history is not simulated", context: { propertyName: ["propertiesWithHistory"] } }]);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function requireLimit(context, value, fallback, max) {
|
|
601
|
+
if (value === undefined) return fallback;
|
|
602
|
+
if (!Number.isInteger(value) || value < 1 || value > max) {
|
|
603
|
+
return validationError(context, `limit must be between 1 and ${max}`, [{ message: `limit ${clipJson(value)} is out of range`, context: { propertyName: ["limit"] } }]);
|
|
604
|
+
}
|
|
605
|
+
return value;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** `archived` query flag: boolean, or the raw malformed string the codec passed through → VALIDATION_ERROR. */
|
|
609
|
+
function requireArchived(context, value) {
|
|
610
|
+
if (value === undefined || value === false) return false;
|
|
611
|
+
if (value === true) return true;
|
|
612
|
+
return validationError(context, `archived must be true or false`, [{ message: `archived ${clipJson(value)} is not a boolean`, context: { propertyName: ["archived"] } }]);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function requireAfter(context, value) {
|
|
616
|
+
if (value === undefined) return undefined;
|
|
617
|
+
if (!isObjectId(String(value))) return validationError(context, "Invalid after cursor", [{ message: `after ${clipJson(value)} is not a valid cursor`, context: { propertyName: ["after"] } }]);
|
|
618
|
+
return String(value);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Reject a free-text filter or query value carrying U+FFFD. The framework decodes query strings and
|
|
623
|
+
* bodies leniently, so malformed percent-encoding (`%E0%A4%A`) arrives as U+FFFD; treating it as
|
|
624
|
+
* search text would silently match nothing. A correctly encoded `%EF%BF%BD` is rejected the same
|
|
625
|
+
* way, while `%ZZ` stays literal and searches normally.
|
|
626
|
+
*/
|
|
627
|
+
function rejectMangled(context, value, propertyName) {
|
|
628
|
+
if (!isMangled(value)) return;
|
|
629
|
+
const message = `${propertyName} contains an invalid character (U+FFFD); check the encoding of the request`;
|
|
630
|
+
return validationError(context, message, [{ message, context: { propertyName: [propertyName] } }]);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** `includeHidden` query flag: boolean, or the raw malformed string the codec passed through. */
|
|
634
|
+
function requireIncludeHidden(context, value) {
|
|
635
|
+
if (value === undefined || value === false) return false;
|
|
636
|
+
if (value === true) return true;
|
|
637
|
+
return validationError(context, `includeHidden must be true or false`, [{ message: `includeHidden ${clipJson(value)} is not a boolean`, context: { propertyName: ["includeHidden"] } }]);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function pagingLink(path, limit, after) {
|
|
641
|
+
return `${path}?limit=${String(limit)}&after=${encodeURIComponent(after)}`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** VALIDATION_ERROR for a response that would not fit the framework's 1 MiB response cap (nothing commits). */
|
|
645
|
+
function responseTooLarge(context, bytes, hint) {
|
|
646
|
+
const message = `The response would be at least ${bytes} bytes, above this Tool's ${RESPONSE_BYTE_BUDGET}-byte response limit; ${hint}`;
|
|
647
|
+
return validationError(context, message, [{ message, context: { responseBytes: [String(bytes)] } }]);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Return `value` when its encoded JSON fits the response budget; otherwise fail with VALIDATION_ERROR. */
|
|
651
|
+
function requireFits(context, value, hint) {
|
|
652
|
+
const bytes = jsonBytes(value);
|
|
653
|
+
return bytes > RESPONSE_BYTE_BUDGET ? responseTooLarge(context, bytes, hint) : value;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const FEWER_PROPERTIES = "request fewer properties or associations";
|
|
657
|
+
const FEWER_INPUTS = "send fewer inputs or request fewer properties";
|
|
658
|
+
|
|
659
|
+
// ---------------------------------------------------------------------------------------------
|
|
660
|
+
// Operations
|
|
661
|
+
// ---------------------------------------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
function objectsList(input, context) {
|
|
664
|
+
const user = requireUser(context);
|
|
665
|
+
const type = requireType(context, input.objectType);
|
|
666
|
+
requireScopes(context, user, readScope(type));
|
|
667
|
+
rejectHistory(context, input);
|
|
668
|
+
const limit = requireLimit(context, input.limit, 10, MAX_LIST_LIMIT);
|
|
669
|
+
const after = requireAfter(context, input.after);
|
|
670
|
+
const archived = requireArchived(context, input.archived);
|
|
671
|
+
const definitions = definitionsFor(context, type);
|
|
672
|
+
const names = readNames(type, input.properties, definitions);
|
|
673
|
+
const results = [];
|
|
674
|
+
let lastId;
|
|
675
|
+
let bytes = PAGE_ENVELOPE_BYTES;
|
|
676
|
+
let cursor = after === undefined ? undefined : padId(after);
|
|
677
|
+
let more = false;
|
|
678
|
+
scan: for (;;) {
|
|
679
|
+
const batch = context.state.scan(type.name, { ...(cursor === undefined ? {} : { afterRowId: cursor }), limit: 500 });
|
|
680
|
+
if (batch.length === 0) break;
|
|
681
|
+
for (const entry of batch) {
|
|
682
|
+
cursor = entry.rowId;
|
|
683
|
+
const record = entry.value;
|
|
684
|
+
if (record.archived !== archived || !isVisible(user, record)) continue;
|
|
685
|
+
if (results.length === limit) {
|
|
686
|
+
more = true;
|
|
687
|
+
break scan;
|
|
688
|
+
}
|
|
689
|
+
// Pages fill by encoded UTF-8 size as well as count; the first record that does not fit starts the next page.
|
|
690
|
+
const view = recordView(record, names, inlineAssociations(context, user, type, record, input.associations));
|
|
691
|
+
const size = jsonBytes(view) + 1;
|
|
692
|
+
if (bytes + size > RESPONSE_BYTE_BUDGET) {
|
|
693
|
+
if (results.length === 0) return responseTooLarge(context, PAGE_ENVELOPE_BYTES + size, FEWER_PROPERTIES);
|
|
694
|
+
more = true;
|
|
695
|
+
break scan;
|
|
696
|
+
}
|
|
697
|
+
bytes += size;
|
|
698
|
+
results.push(view);
|
|
699
|
+
lastId = record.id;
|
|
700
|
+
}
|
|
701
|
+
if (batch.length < 500) break;
|
|
702
|
+
}
|
|
703
|
+
const body = { results };
|
|
704
|
+
if (more) {
|
|
705
|
+
const last = lastId;
|
|
706
|
+
body.paging = { next: { after: last, link: pagingLink(`/crm/v3/objects/${type.name}`, limit, last) } };
|
|
707
|
+
}
|
|
708
|
+
return body;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function objectsGet(input, context) {
|
|
712
|
+
const user = requireUser(context);
|
|
713
|
+
const type = requireType(context, input.objectType);
|
|
714
|
+
requireScopes(context, user, readScope(type));
|
|
715
|
+
rejectHistory(context, input);
|
|
716
|
+
const archived = requireArchived(context, input.archived);
|
|
717
|
+
const definitions = definitionsFor(context, type);
|
|
718
|
+
const record = requireRecord(context, user, type, definitions, input.objectId, input.idProperty, archived);
|
|
719
|
+
return requireFits(context, recordView(record, readNames(type, input.properties, definitions), inlineAssociations(context, user, type, record, input.associations)), FEWER_PROPERTIES);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function objectsCreate(input, context) {
|
|
723
|
+
const user = requireUser(context);
|
|
724
|
+
const type = requireType(context, input.objectType);
|
|
725
|
+
requireScopes(context, user, writeScope(type));
|
|
726
|
+
const now = isoNow(context);
|
|
727
|
+
const definitions = definitionsFor(context, type);
|
|
728
|
+
const merged = mergeProperties(context, user, type, definitions, input.properties, null, now);
|
|
729
|
+
const links = resolveInlineAssociations(context, user, type, input.associations);
|
|
730
|
+
const record = createRecord(context, type, merged.properties, now);
|
|
731
|
+
applyInlineAssociations(context, type, record.id, links, now);
|
|
732
|
+
return requireFits(context, recordView(record, writeNames(type, record, definitions)), "shorten the property values");
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function objectsUpdate(input, context) {
|
|
736
|
+
const user = requireUser(context);
|
|
737
|
+
const type = requireType(context, input.objectType);
|
|
738
|
+
requireScopes(context, user, writeScope(type));
|
|
739
|
+
const definitions = definitionsFor(context, type);
|
|
740
|
+
const lookups = uniqueLookups(context, type);
|
|
741
|
+
const existing = requireRecord(context, user, type, definitions, input.objectId, input.idProperty, false, lookups);
|
|
742
|
+
const now = isoNow(context);
|
|
743
|
+
const merged = mergeProperties(context, user, type, definitions, input.properties, existing, now, lookups);
|
|
744
|
+
const record = updateRecord(context, type, existing, merged, now);
|
|
745
|
+
return requireFits(context, recordView(record, writeNames(type, record, definitions)), "shorten the property values");
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function objectsArchive(input, context) {
|
|
749
|
+
const user = requireUser(context);
|
|
750
|
+
const type = requireType(context, input.objectType);
|
|
751
|
+
requireScopes(context, user, writeScope(type));
|
|
752
|
+
const record = loadRecord(context, user, type, String(input.objectId));
|
|
753
|
+
if (record === null) return notFound(context);
|
|
754
|
+
const now = isoNow(context);
|
|
755
|
+
saveRecord(context, type, { ...record, archived: true, archivedAt: now, updatedAt: now });
|
|
756
|
+
return {};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function objectsBatchRead(input, context) {
|
|
760
|
+
const user = requireUser(context);
|
|
761
|
+
const type = requireType(context, input.objectType);
|
|
762
|
+
requireScopes(context, user, readScope(type));
|
|
763
|
+
rejectHistory(context, input);
|
|
764
|
+
const inputs = requireInputs(context, input.inputs);
|
|
765
|
+
const definitions = definitionsFor(context, type);
|
|
766
|
+
const names = readNames(type, input.properties, definitions);
|
|
767
|
+
const now = isoNow(context);
|
|
768
|
+
const results = [];
|
|
769
|
+
const missing = [];
|
|
770
|
+
const lookups = uniqueLookups(context, type);
|
|
771
|
+
for (const entry of inputs) {
|
|
772
|
+
const id = String(entry?.id ?? "");
|
|
773
|
+
const record = resolveRecord(context, user, type, definitions, id, input.idProperty, false, lookups);
|
|
774
|
+
if (record === null) missing.push(id);
|
|
775
|
+
else results.push(recordView(record, names));
|
|
776
|
+
}
|
|
777
|
+
return requireFits(context, batchEnvelope(results, missing.length > 0 ? [missingObjectsError(type, missing)] : [], now, now), FEWER_INPUTS);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function objectsBatchCreate(input, context) {
|
|
781
|
+
const user = requireUser(context);
|
|
782
|
+
const type = requireType(context, input.objectType);
|
|
783
|
+
requireScopes(context, user, writeScope(type));
|
|
784
|
+
const inputs = requireInputs(context, input.inputs);
|
|
785
|
+
const now = isoNow(context);
|
|
786
|
+
const definitions = definitionsFor(context, type);
|
|
787
|
+
const lookups = uniqueLookups(context, type);
|
|
788
|
+
const prepared = inputs.map((entry) => ({
|
|
789
|
+
merged: mergeProperties(context, user, type, definitions, entry?.properties, null, now, lookups),
|
|
790
|
+
links: resolveInlineAssociations(context, user, type, entry?.associations),
|
|
791
|
+
}));
|
|
792
|
+
requireUniqueEmails(
|
|
793
|
+
context,
|
|
794
|
+
type,
|
|
795
|
+
prepared.map((entry) => entry.merged.properties),
|
|
796
|
+
);
|
|
797
|
+
const results = prepared.map(({ merged, links }) => {
|
|
798
|
+
const record = createRecord(context, type, merged.properties, now);
|
|
799
|
+
applyInlineAssociations(context, type, record.id, links, now);
|
|
800
|
+
return recordView(record, writeNames(type, record, definitions));
|
|
801
|
+
});
|
|
802
|
+
return requireFits(context, batchEnvelope(results, [], now, now), FEWER_INPUTS);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function objectsBatchUpdate(input, context) {
|
|
806
|
+
const user = requireUser(context);
|
|
807
|
+
const type = requireType(context, input.objectType);
|
|
808
|
+
requireScopes(context, user, writeScope(type));
|
|
809
|
+
const inputs = requireInputs(context, input.inputs);
|
|
810
|
+
const now = isoNow(context);
|
|
811
|
+
const definitions = definitionsFor(context, type);
|
|
812
|
+
const results = [];
|
|
813
|
+
const errors = [];
|
|
814
|
+
const lookups = uniqueLookups(context, type);
|
|
815
|
+
for (const entry of inputs) {
|
|
816
|
+
const id = String(entry?.id ?? "");
|
|
817
|
+
const existing = resolveRecord(context, user, type, definitions, id, entry?.idProperty, false, lookups);
|
|
818
|
+
if (existing === null) {
|
|
819
|
+
errors.push(missingRecordError(type, id));
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
const merged = mergeProperties(context, user, type, definitions, entry?.properties, existing, now, lookups);
|
|
823
|
+
const record = updateRecord(context, type, existing, merged, now);
|
|
824
|
+
lookups.replace(existing, record);
|
|
825
|
+
results.push(recordView(record, writeNames(type, record, definitions)));
|
|
826
|
+
}
|
|
827
|
+
return requireFits(context, batchEnvelope(results, errors, now, now), FEWER_INPUTS);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function objectsSearch(input, context) {
|
|
831
|
+
const user = requireUser(context);
|
|
832
|
+
const type = requireType(context, input.objectType);
|
|
833
|
+
requireScopes(context, user, readScope(type));
|
|
834
|
+
const definitions = definitionsFor(context, type);
|
|
835
|
+
const groups = normalizeFilterGroups(input.filterGroups, definitions);
|
|
836
|
+
if (groups.error !== undefined) return validationError(context, groups.error, [{ message: groups.error, context: { propertyName: ["filterGroups"] } }]);
|
|
837
|
+
const sorts = normalizeSorts(input.sorts, definitions);
|
|
838
|
+
if (sorts.error !== undefined) return validationError(context, sorts.error, [{ message: sorts.error, context: { propertyName: ["sorts"] } }]);
|
|
839
|
+
const limit = requireLimit(context, input.limit, 10, MAX_SEARCH_LIMIT);
|
|
840
|
+
const after = input.after === undefined ? "0" : String(input.after);
|
|
841
|
+
if (!/^(0|[1-9][0-9]{0,6})$/.test(after) || Number(after) >= MAX_SEARCH_OFFSET) {
|
|
842
|
+
return validationError(context, `Invalid after cursor: paging beyond ${MAX_SEARCH_OFFSET} results is not supported`, [{ message: "after must be a decimal offset below 10000", context: { propertyName: ["after"] } }]);
|
|
843
|
+
}
|
|
844
|
+
const offset = Number(after);
|
|
845
|
+
const query = typeof input.query === "string" ? input.query : "";
|
|
846
|
+
rejectMangled(context, query, "query");
|
|
847
|
+
if (query.length > MAX_SEARCH_TEXT) {
|
|
848
|
+
return validationError(context, `query exceeds the maximum length of ${MAX_SEARCH_TEXT} characters`, [{ message: `query has ${query.length} characters`, context: { propertyName: ["query"] } }]);
|
|
849
|
+
}
|
|
850
|
+
const budget = createWorkBudget();
|
|
851
|
+
const expensive = () => {
|
|
852
|
+
const message = `This search needs more than ${MAX_MATCH_WORK} characters of matching work; narrow the filters or shorten the property values it scans`;
|
|
853
|
+
return validationError(context, message, [{ message, context: { propertyName: ["filterGroups"] } }]);
|
|
854
|
+
};
|
|
855
|
+
const matches = createRecordMatcher(groups.groups, query, SEARCHABLE_PROPERTIES[type.name], budget);
|
|
856
|
+
const matched = [];
|
|
857
|
+
for (const record of allRows(context, type.name)) {
|
|
858
|
+
if (record.archived || !isVisible(user, record)) continue;
|
|
859
|
+
if (matches(record.properties)) matched.push(record);
|
|
860
|
+
if (budget.exceeded) return expensive();
|
|
861
|
+
}
|
|
862
|
+
if (sorts.sort !== undefined) {
|
|
863
|
+
sortRecords(matched, sorts.sort, budget);
|
|
864
|
+
if (budget.exceeded) return expensive();
|
|
865
|
+
}
|
|
866
|
+
const total = Math.min(matched.length, MAX_SEARCH_OFFSET);
|
|
867
|
+
const names = readNames(type, input.properties, definitions);
|
|
868
|
+
// Pages fill by encoded UTF-8 size as well as count; `after` is the offset of the first record not returned.
|
|
869
|
+
const results = [];
|
|
870
|
+
let bytes = PAGE_ENVELOPE_BYTES;
|
|
871
|
+
for (let index = offset; index < Math.min(offset + limit, total); index += 1) {
|
|
872
|
+
const view = recordView(matched[index], names);
|
|
873
|
+
const size = jsonBytes(view) + 1;
|
|
874
|
+
if (bytes + size > RESPONSE_BYTE_BUDGET) {
|
|
875
|
+
if (results.length === 0) return responseTooLarge(context, PAGE_ENVELOPE_BYTES + size, "request fewer properties");
|
|
876
|
+
break;
|
|
877
|
+
}
|
|
878
|
+
bytes += size;
|
|
879
|
+
results.push(view);
|
|
880
|
+
}
|
|
881
|
+
const body = { total, results };
|
|
882
|
+
const next = offset + results.length;
|
|
883
|
+
if (next < total) body.paging = { next: { after: String(next) } };
|
|
884
|
+
return body;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function associationsList(input, context) {
|
|
888
|
+
const user = requireUser(context);
|
|
889
|
+
const fromType = requireType(context, input.objectType);
|
|
890
|
+
const toType = requireType(context, input.toObjectType);
|
|
891
|
+
requireScopes(context, user, readScope(fromType), readScope(toType));
|
|
892
|
+
const limit = requireLimit(context, input.limit, MAX_ASSOCIATION_LIMIT, MAX_ASSOCIATION_LIMIT);
|
|
893
|
+
const after = requireAfter(context, input.after);
|
|
894
|
+
const record = loadRecord(context, user, fromType, String(input.objectId));
|
|
895
|
+
if (record === null) return notFound(context);
|
|
896
|
+
const definitions = labelDefinitions(context, fromType, toType);
|
|
897
|
+
const links = visibleLinks(context, user, fromType, record.id, toType).filter((link) => after === undefined || Number(link.toObjectId) > Number(after));
|
|
898
|
+
const page = links.slice(0, limit);
|
|
899
|
+
const body = { results: page.map((link) => linkView(link, definitions)) };
|
|
900
|
+
if (links.length > limit) {
|
|
901
|
+
const last = page[page.length - 1].toObjectId;
|
|
902
|
+
body.paging = { next: { after: last, link: pagingLink(`/crm/v4/objects/${fromType.name}/${record.id}/associations/${toType.name}`, limit, last) } };
|
|
903
|
+
}
|
|
904
|
+
return body;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function requirePair(context, user, input) {
|
|
908
|
+
const fromType = requireType(context, input.fromObjectType);
|
|
909
|
+
const toType = requireType(context, input.toObjectType);
|
|
910
|
+
requireScopes(context, user, writeScope(fromType), readScope(toType));
|
|
911
|
+
const from = loadRecord(context, user, fromType, String(input.fromObjectId));
|
|
912
|
+
if (from === null) return notFound(context, `No ${fromType.singular} with ID ${clip(input.fromObjectId)} exists`);
|
|
913
|
+
const to = loadRecord(context, user, toType, String(input.toObjectId));
|
|
914
|
+
if (to === null) return notFound(context, `No ${toType.singular} with ID ${clip(input.toObjectId)} exists`);
|
|
915
|
+
return { fromType, toType, from, to };
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function associationsCreateDefault(input, context) {
|
|
919
|
+
const user = requireUser(context);
|
|
920
|
+
const { fromType, toType, from, to } = requirePair(context, user, input);
|
|
921
|
+
const definition = defaultDefinition(context, fromType, toType);
|
|
922
|
+
if (definition === undefined) {
|
|
923
|
+
return validationError(context, `No default association type is defined between ${fromType.singular} and ${toType.singular}`, [
|
|
924
|
+
{ message: "Default association type missing", context: { fromObjectTypeId: [fromType.typeId], toObjectTypeId: [toType.typeId] } },
|
|
925
|
+
]);
|
|
926
|
+
}
|
|
927
|
+
associate(context, fromType, from.id, toType, to.id, [definition], isoNow(context));
|
|
928
|
+
const spec = (typeId) => ({ associationCategory: "HUBSPOT_DEFINED", associationTypeId: typeId });
|
|
929
|
+
return {
|
|
930
|
+
results: [
|
|
931
|
+
{ fromObjectTypeId: fromType.typeId, fromObjectId: from.id, toObjectTypeId: toType.typeId, toObjectId: to.id, associationSpec: spec(definition.typeId) },
|
|
932
|
+
{ fromObjectTypeId: toType.typeId, fromObjectId: to.id, toObjectTypeId: fromType.typeId, toObjectId: from.id, associationSpec: spec(definition.inverseTypeId) },
|
|
933
|
+
],
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function associationsCreate(input, context) {
|
|
938
|
+
const user = requireUser(context);
|
|
939
|
+
const { fromType, toType, from, to } = requirePair(context, user, input);
|
|
940
|
+
const definitions = requireTypeSpecs(context, fromType, toType, input.types);
|
|
941
|
+
associate(context, fromType, from.id, toType, to.id, definitions, isoNow(context));
|
|
942
|
+
const link = linkRow(context, fromType, from.id, toType, to.id);
|
|
943
|
+
return associationResult(fromType, from.id, toType, to.id, labelsOf(link, labelDefinitions(context, fromType, toType)));
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function associationsBatchCreate(input, context) {
|
|
947
|
+
const user = requireUser(context);
|
|
948
|
+
const fromType = requireType(context, input.fromObjectType);
|
|
949
|
+
const toType = requireType(context, input.toObjectType);
|
|
950
|
+
requireScopes(context, user, writeScope(fromType), readScope(toType));
|
|
951
|
+
const inputs = requireInputs(context, input.inputs);
|
|
952
|
+
const now = isoNow(context);
|
|
953
|
+
const prepared = inputs.map((entry) => ({
|
|
954
|
+
fromId: String(entry?.from?.id ?? ""),
|
|
955
|
+
toId: String(entry?.to?.id ?? ""),
|
|
956
|
+
definitions: requireTypeSpecs(context, fromType, toType, entry?.types),
|
|
957
|
+
}));
|
|
958
|
+
const results = [];
|
|
959
|
+
const errors = [];
|
|
960
|
+
const allDefinitions = labelDefinitions(context, fromType, toType);
|
|
961
|
+
for (const { fromId, toId, definitions } of prepared) {
|
|
962
|
+
if (loadRecord(context, user, fromType, fromId) === null) {
|
|
963
|
+
errors.push(missingRecordError(fromType, fromId));
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (loadRecord(context, user, toType, toId) === null) {
|
|
967
|
+
errors.push(missingRecordError(toType, toId));
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
associate(context, fromType, fromId, toType, toId, definitions, now);
|
|
971
|
+
results.push(associationResult(fromType, fromId, toType, toId, labelsOf(linkRow(context, fromType, fromId, toType, toId), allDefinitions)));
|
|
972
|
+
}
|
|
973
|
+
return batchEnvelope(results, errors, now, now);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function associationsArchive(input, context) {
|
|
977
|
+
const user = requireUser(context);
|
|
978
|
+
const { fromType, toType, from, to } = requirePair(context, user, input);
|
|
979
|
+
const forward = linkRow(context, fromType, from.id, toType, to.id);
|
|
980
|
+
if (forward !== null) removeTypes(context, forward, forward.typeIds);
|
|
981
|
+
const backward = linkRow(context, toType, to.id, fromType, from.id);
|
|
982
|
+
if (backward !== null) removeTypes(context, backward, backward.typeIds);
|
|
983
|
+
return {};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function associationsBatchRead(input, context) {
|
|
987
|
+
const user = requireUser(context);
|
|
988
|
+
const fromType = requireType(context, input.fromObjectType);
|
|
989
|
+
const toType = requireType(context, input.toObjectType);
|
|
990
|
+
requireScopes(context, user, readScope(fromType), readScope(toType));
|
|
991
|
+
const inputs = requireInputs(context, input.inputs);
|
|
992
|
+
const now = isoNow(context);
|
|
993
|
+
const definitions = labelDefinitions(context, fromType, toType);
|
|
994
|
+
const results = [];
|
|
995
|
+
const errors = [];
|
|
996
|
+
for (const entry of inputs) {
|
|
997
|
+
const id = String(entry?.id ?? "");
|
|
998
|
+
if (loadRecord(context, user, fromType, id) === null) {
|
|
999
|
+
errors.push(missingRecordError(fromType, id));
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
const links = visibleLinks(context, user, fromType, id, toType);
|
|
1003
|
+
if (links.length > 0) results.push({ from: { id }, to: links.map((link) => linkView(link, definitions)) });
|
|
1004
|
+
}
|
|
1005
|
+
return requireFits(context, batchEnvelope(results, errors, now, now), "send fewer inputs");
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function associationsListLabels(input, context) {
|
|
1009
|
+
const user = requireUser(context);
|
|
1010
|
+
const fromType = requireType(context, input.fromObjectType);
|
|
1011
|
+
const toType = requireType(context, input.toObjectType);
|
|
1012
|
+
requireScopes(context, user, readScope(fromType), readScope(toType));
|
|
1013
|
+
const results = [...labelDefinitions(context, fromType, toType).values()].sort((left, right) => left.typeId - right.typeId).map(labelView);
|
|
1014
|
+
return { results };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function ownerView(owner) {
|
|
1018
|
+
return {
|
|
1019
|
+
id: owner.id,
|
|
1020
|
+
email: owner.email,
|
|
1021
|
+
type: owner.type,
|
|
1022
|
+
firstName: owner.firstName,
|
|
1023
|
+
lastName: owner.lastName,
|
|
1024
|
+
userId: owner.userId,
|
|
1025
|
+
userIdIncludingInactive: owner.userIdIncludingInactive,
|
|
1026
|
+
createdAt: owner.createdAt,
|
|
1027
|
+
updatedAt: owner.updatedAt,
|
|
1028
|
+
archived: owner.archived,
|
|
1029
|
+
teams: owner.teams,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function ownersList(input, context) {
|
|
1034
|
+
const user = requireUser(context);
|
|
1035
|
+
requireScopes(context, user, "crm.objects.owners.read");
|
|
1036
|
+
const limit = requireLimit(context, input.limit, 100, MAX_OWNER_LIMIT);
|
|
1037
|
+
const after = requireAfter(context, input.after);
|
|
1038
|
+
const archived = requireArchived(context, input.archived);
|
|
1039
|
+
rejectMangled(context, input.email, "email");
|
|
1040
|
+
const email = typeof input.email === "string" ? input.email.trim().toLowerCase() : undefined;
|
|
1041
|
+
const matched = allRows(context, "owners").filter(
|
|
1042
|
+
(owner) => owner.archived === archived && (email === undefined || owner.email.toLowerCase() === email) && (after === undefined || Number(owner.id) > Number(after)),
|
|
1043
|
+
);
|
|
1044
|
+
const page = matched.slice(0, limit);
|
|
1045
|
+
const body = { results: page.map(ownerView) };
|
|
1046
|
+
if (matched.length > limit) {
|
|
1047
|
+
const last = page[page.length - 1].id;
|
|
1048
|
+
body.paging = { next: { after: last, link: pagingLink("/crm/v3/owners", limit, last) } };
|
|
1049
|
+
}
|
|
1050
|
+
return body;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function ownersGet(input, context) {
|
|
1054
|
+
const user = requireUser(context);
|
|
1055
|
+
requireScopes(context, user, "crm.objects.owners.read");
|
|
1056
|
+
const includeArchived = requireArchived(context, input.archived);
|
|
1057
|
+
const idProperty = input.idProperty ?? "id";
|
|
1058
|
+
if (idProperty !== "id" && idProperty !== "userId") {
|
|
1059
|
+
return validationError(context, `Invalid idProperty: ${clip(idProperty)}`, [{ message: "idProperty must be id or userId", context: { propertyName: ["idProperty"] } }]);
|
|
1060
|
+
}
|
|
1061
|
+
const id = String(input.ownerId);
|
|
1062
|
+
const owner =
|
|
1063
|
+
idProperty === "userId"
|
|
1064
|
+
? allRows(context, "owners").find((row) => String(row.userIdIncludingInactive) === id) ?? null
|
|
1065
|
+
: isObjectId(id)
|
|
1066
|
+
? context.state.get("owners", padId(id))
|
|
1067
|
+
: null;
|
|
1068
|
+
if (owner === null || owner === undefined || (owner.archived && !includeArchived)) return notFound(context, `Owner ${clip(id)} not found`);
|
|
1069
|
+
return ownerView(owner);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function requireDealType(context, value) {
|
|
1073
|
+
const type = requireType(context, value);
|
|
1074
|
+
if (type.name !== "deals") return notFound(context, `Unable to infer object type from: ${clip(value)}`);
|
|
1075
|
+
return type;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function pipelineView(row) {
|
|
1079
|
+
const { objectType: _objectType, ...pipeline } = row;
|
|
1080
|
+
return pipeline;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** The deal pipeline row, or null for ids that cannot name one (empty, containing `/`, or beyond the row-id bound). */
|
|
1084
|
+
function pipelineRow(context, pipelineId) {
|
|
1085
|
+
return isRowKey(pipelineId, "deals/") ? context.state.get("pipelines", `deals/${pipelineId}`) : null;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function requirePipeline(context, pipelineId) {
|
|
1089
|
+
const row = pipelineRow(context, pipelineId);
|
|
1090
|
+
return row === null ? notFound(context, `Pipeline ${clip(pipelineId)} not found`) : row;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function pipelinesList(input, context) {
|
|
1094
|
+
const user = requireUser(context);
|
|
1095
|
+
requireDealType(context, input.objectType);
|
|
1096
|
+
requireScopes(context, user, "crm.objects.deals.read");
|
|
1097
|
+
const results = prefixRows(context, "pipelines", "deals/")
|
|
1098
|
+
.sort((left, right) => left.displayOrder - right.displayOrder || compareStrings(left.id, right.id))
|
|
1099
|
+
.map(pipelineView);
|
|
1100
|
+
return { results };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function pipelinesGet(input, context) {
|
|
1104
|
+
const user = requireUser(context);
|
|
1105
|
+
requireDealType(context, input.objectType);
|
|
1106
|
+
requireScopes(context, user, "crm.objects.deals.read");
|
|
1107
|
+
return pipelineView(requirePipeline(context, input.pipelineId));
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function pipelinesListStages(input, context) {
|
|
1111
|
+
const user = requireUser(context);
|
|
1112
|
+
requireDealType(context, input.objectType);
|
|
1113
|
+
requireScopes(context, user, "crm.objects.deals.read");
|
|
1114
|
+
const pipeline = requirePipeline(context, input.pipelineId);
|
|
1115
|
+
return { results: [...pipeline.stages].sort((left, right) => left.displayOrder - right.displayOrder) };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function propertyView(definition) {
|
|
1119
|
+
const { objectType: _objectType, ...property } = definition;
|
|
1120
|
+
return property;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function propertiesList(input, context) {
|
|
1124
|
+
const user = requireUser(context);
|
|
1125
|
+
const type = requireType(context, input.objectType);
|
|
1126
|
+
requireScopes(context, user, schemaReadScope(type));
|
|
1127
|
+
const archived = requireArchived(context, input.archived);
|
|
1128
|
+
const includeHidden = requireIncludeHidden(context, input.includeHidden);
|
|
1129
|
+
const results = [];
|
|
1130
|
+
if (!archived) for (const definition of DEFINED_PROPERTIES[type.name].values()) if (includeHidden || !definition.hidden) results.push(definition);
|
|
1131
|
+
for (const row of prefixRows(context, "properties", `${type.name}/`)) if (row.archived === archived && (includeHidden || !row.hidden)) results.push(row);
|
|
1132
|
+
results.sort((left, right) => compareStrings(left.groupName, right.groupName) || left.displayOrder - right.displayOrder || compareStrings(left.name, right.name));
|
|
1133
|
+
return { results: results.map(propertyView) };
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function propertiesGet(input, context) {
|
|
1137
|
+
const user = requireUser(context);
|
|
1138
|
+
const type = requireType(context, input.objectType);
|
|
1139
|
+
requireScopes(context, user, schemaReadScope(type));
|
|
1140
|
+
const includeArchived = requireArchived(context, input.archived);
|
|
1141
|
+
const name = String(input.propertyName);
|
|
1142
|
+
const defined = DEFINED_PROPERTIES[type.name].get(name);
|
|
1143
|
+
if (defined !== undefined) return propertyView(defined);
|
|
1144
|
+
const row = isRowKey(name, `${type.name}/`) ? context.state.get("properties", `${type.name}/${name}`) : null;
|
|
1145
|
+
if (row === null || (row.archived && !includeArchived)) return notFound(context, `Property ${clip(name)} does not exist`);
|
|
1146
|
+
return propertyView(row);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function accountGetDetails(_input, context) {
|
|
1150
|
+
const user = requireUser(context);
|
|
1151
|
+
requireScopes(context, user, "oauth");
|
|
1152
|
+
return {
|
|
1153
|
+
...ACCOUNT,
|
|
1154
|
+
user: { id: user.owner.userId, email: user.owner.email, firstName: user.owner.firstName, lastName: user.owner.lastName, ownerId: user.owner.id },
|
|
1155
|
+
scopes: user.scopes === null ? [...ALL_SCOPES] : [...user.scopes],
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const operations = {
|
|
1160
|
+
"account.get-details": accountGetDetails,
|
|
1161
|
+
"objects.list": objectsList,
|
|
1162
|
+
"objects.get": objectsGet,
|
|
1163
|
+
"objects.create": objectsCreate,
|
|
1164
|
+
"objects.update": objectsUpdate,
|
|
1165
|
+
"objects.archive": objectsArchive,
|
|
1166
|
+
"objects.batch-read": objectsBatchRead,
|
|
1167
|
+
"objects.batch-create": objectsBatchCreate,
|
|
1168
|
+
"objects.batch-update": objectsBatchUpdate,
|
|
1169
|
+
"objects.search": objectsSearch,
|
|
1170
|
+
"associations.list": associationsList,
|
|
1171
|
+
"associations.create-default": associationsCreateDefault,
|
|
1172
|
+
"associations.create": associationsCreate,
|
|
1173
|
+
"associations.batch-create": associationsBatchCreate,
|
|
1174
|
+
"associations.archive": associationsArchive,
|
|
1175
|
+
"associations.batch-read": associationsBatchRead,
|
|
1176
|
+
"associations.list-labels": associationsListLabels,
|
|
1177
|
+
"owners.list": ownersList,
|
|
1178
|
+
"owners.get": ownersGet,
|
|
1179
|
+
"pipelines.list": pipelinesList,
|
|
1180
|
+
"pipelines.get": pipelinesGet,
|
|
1181
|
+
"pipelines.list-stages": pipelinesListStages,
|
|
1182
|
+
"properties.list": propertiesList,
|
|
1183
|
+
"properties.get": propertiesGet,
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
// ---------------------------------------------------------------------------------------------
|
|
1187
|
+
// HTTP codecs (pure): HubSpot paths in, HubSpot bodies/headers out
|
|
1188
|
+
// ---------------------------------------------------------------------------------------------
|
|
1189
|
+
|
|
1190
|
+
/** Codec for one route: success bodies pass through (or `select`), errors use the HubSpot envelope. */
|
|
1191
|
+
function route(decode, options = {}) {
|
|
1192
|
+
return {
|
|
1193
|
+
decode,
|
|
1194
|
+
encode({ invocation, outcome }) {
|
|
1195
|
+
const headers = responseHeaders(invocation.correlationId, isRateLimited(outcome));
|
|
1196
|
+
if (outcome.status !== "ok") return { headers, body: { kind: "json", value: hubspotError(outcome, invocation.correlationId) } };
|
|
1197
|
+
if (options.empty) return { headers, body: { kind: "empty" } };
|
|
1198
|
+
return { headers, body: { kind: "json", value: options.select === undefined ? outcome.value : options.select(outcome.value) } };
|
|
1199
|
+
},
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function readQuery(request) {
|
|
1204
|
+
return defined({
|
|
1205
|
+
properties: list(request.query, "properties"),
|
|
1206
|
+
propertiesWithHistory: list(request.query, "propertiesWithHistory"),
|
|
1207
|
+
associations: list(request.query, "associations"),
|
|
1208
|
+
archived: bool(request.query, "archived"),
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function stringOrUndefined(value) {
|
|
1213
|
+
return typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function batchBody(request, keys) {
|
|
1217
|
+
const body = jsonBody(request);
|
|
1218
|
+
return defined(Object.fromEntries(keys.map((key) => [key, body[key]])));
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const http = {
|
|
1222
|
+
"get-account-details": route(() => ({ arguments: {} }), {
|
|
1223
|
+
select: (value) => {
|
|
1224
|
+
const { user: _user, scopes: _scopes, ...details } = value;
|
|
1225
|
+
return details;
|
|
1226
|
+
},
|
|
1227
|
+
}),
|
|
1228
|
+
"list-objects": route((request) => ({
|
|
1229
|
+
arguments: defined({ objectType: request.path.objectType, limit: int(request.query, "limit"), after: str(request.query, "after"), ...readQuery(request) }),
|
|
1230
|
+
})),
|
|
1231
|
+
"get-object": route((request) => ({
|
|
1232
|
+
arguments: defined({ objectType: request.path.objectType, objectId: request.path.objectId, idProperty: str(request.query, "idProperty"), ...readQuery(request) }),
|
|
1233
|
+
})),
|
|
1234
|
+
"create-object": route((request) => operationInput(request, defined({ objectType: request.path.objectType, ...batchBody(request, ["properties", "associations"]) }))),
|
|
1235
|
+
"update-object": route((request) =>
|
|
1236
|
+
operationInput(request, defined({ objectType: request.path.objectType, objectId: request.path.objectId, idProperty: str(request.query, "idProperty"), ...batchBody(request, ["properties"]) })),
|
|
1237
|
+
),
|
|
1238
|
+
"archive-object": route((request) => operationInput(request, { objectType: request.path.objectType, objectId: request.path.objectId }), { empty: true }),
|
|
1239
|
+
"batch-read-objects": route((request) => ({ arguments: defined({ objectType: request.path.objectType, ...batchBody(request, ["inputs", "properties", "propertiesWithHistory", "idProperty"]) }) })),
|
|
1240
|
+
"batch-create-objects": route((request) => operationInput(request, defined({ objectType: request.path.objectType, ...batchBody(request, ["inputs"]) }))),
|
|
1241
|
+
"batch-update-objects": route((request) => operationInput(request, defined({ objectType: request.path.objectType, ...batchBody(request, ["inputs"]) }))),
|
|
1242
|
+
"search-objects": route((request) => {
|
|
1243
|
+
const body = jsonBody(request);
|
|
1244
|
+
return {
|
|
1245
|
+
arguments: defined({
|
|
1246
|
+
objectType: request.path.objectType,
|
|
1247
|
+
filterGroups: body.filterGroups,
|
|
1248
|
+
query: body.query,
|
|
1249
|
+
sorts: body.sorts,
|
|
1250
|
+
properties: body.properties,
|
|
1251
|
+
limit: body.limit,
|
|
1252
|
+
after: stringOrUndefined(body.after),
|
|
1253
|
+
}),
|
|
1254
|
+
};
|
|
1255
|
+
}),
|
|
1256
|
+
"list-associations": route((request) => ({
|
|
1257
|
+
arguments: defined({
|
|
1258
|
+
objectType: request.path.objectType,
|
|
1259
|
+
objectId: request.path.objectId,
|
|
1260
|
+
toObjectType: request.path.toObjectType,
|
|
1261
|
+
limit: int(request.query, "limit"),
|
|
1262
|
+
after: str(request.query, "after"),
|
|
1263
|
+
}),
|
|
1264
|
+
})),
|
|
1265
|
+
"create-default-association": route(
|
|
1266
|
+
(request) =>
|
|
1267
|
+
operationInput(request, {
|
|
1268
|
+
fromObjectType: request.path.fromObjectType,
|
|
1269
|
+
fromObjectId: request.path.fromObjectId,
|
|
1270
|
+
toObjectType: request.path.toObjectType,
|
|
1271
|
+
toObjectId: request.path.toObjectId,
|
|
1272
|
+
}),
|
|
1273
|
+
{ select: (value) => value.results },
|
|
1274
|
+
),
|
|
1275
|
+
"create-association": route((request) =>
|
|
1276
|
+
operationInput(request, {
|
|
1277
|
+
fromObjectType: request.path.fromObjectType,
|
|
1278
|
+
fromObjectId: request.path.fromObjectId,
|
|
1279
|
+
toObjectType: request.path.toObjectType,
|
|
1280
|
+
toObjectId: request.path.toObjectId,
|
|
1281
|
+
types: jsonArrayBody(request),
|
|
1282
|
+
}),
|
|
1283
|
+
),
|
|
1284
|
+
"archive-association": route(
|
|
1285
|
+
(request) =>
|
|
1286
|
+
operationInput(request, {
|
|
1287
|
+
fromObjectType: request.path.fromObjectType,
|
|
1288
|
+
fromObjectId: request.path.fromObjectId,
|
|
1289
|
+
toObjectType: request.path.toObjectType,
|
|
1290
|
+
toObjectId: request.path.toObjectId,
|
|
1291
|
+
}),
|
|
1292
|
+
{ empty: true },
|
|
1293
|
+
),
|
|
1294
|
+
"batch-create-associations": route((request) =>
|
|
1295
|
+
operationInput(request, defined({ fromObjectType: request.path.fromObjectType, toObjectType: request.path.toObjectType, ...batchBody(request, ["inputs"]) })),
|
|
1296
|
+
),
|
|
1297
|
+
"batch-read-associations": route((request) => ({
|
|
1298
|
+
arguments: defined({ fromObjectType: request.path.fromObjectType, toObjectType: request.path.toObjectType, ...batchBody(request, ["inputs"]) }),
|
|
1299
|
+
})),
|
|
1300
|
+
"list-association-labels": route((request) => ({ arguments: { fromObjectType: request.path.fromObjectType, toObjectType: request.path.toObjectType } })),
|
|
1301
|
+
"list-owners": route((request) => ({
|
|
1302
|
+
arguments: defined({ email: str(request.query, "email"), limit: int(request.query, "limit"), after: str(request.query, "after"), archived: bool(request.query, "archived") }),
|
|
1303
|
+
})),
|
|
1304
|
+
"get-owner": route((request) => ({
|
|
1305
|
+
arguments: defined({ ownerId: request.path.ownerId, idProperty: str(request.query, "idProperty"), archived: bool(request.query, "archived") }),
|
|
1306
|
+
})),
|
|
1307
|
+
"list-pipelines": route((request) => ({ arguments: { objectType: request.path.objectType } })),
|
|
1308
|
+
"get-pipeline": route((request) => ({ arguments: { objectType: request.path.objectType, pipelineId: request.path.pipelineId } })),
|
|
1309
|
+
"list-pipeline-stages": route((request) => ({ arguments: { objectType: request.path.objectType, pipelineId: request.path.pipelineId } })),
|
|
1310
|
+
"list-properties": route((request) => ({
|
|
1311
|
+
arguments: defined({ objectType: request.path.objectType, archived: bool(request.query, "archived"), includeHidden: bool(request.query, "includeHidden") ?? true }),
|
|
1312
|
+
})),
|
|
1313
|
+
"get-property": route((request) => ({
|
|
1314
|
+
arguments: defined({ objectType: request.path.objectType, propertyName: request.path.propertyName, archived: bool(request.query, "archived") }),
|
|
1315
|
+
})),
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
export default { operations, http };
|