@volter/twin-hubspot 0.1.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.
- package/LICENSE +202 -0
- package/README.md +197 -0
- package/client/hubspot-mirror.css +43 -0
- package/client/hubspot-mirror.tsx +132 -0
- package/dist/client/hubspot-mirror.bundle.js +449 -0
- package/dist/client/hubspot-mirror.css +43 -0
- package/dist/client/hubspot-mirror.d.ts +15 -0
- package/dist/client/hubspot-mirror.js +59 -0
- package/dist/client/hubspot-mirror.tsx +132 -0
- package/dist/src/accounts.d.ts +30 -0
- package/dist/src/accounts.js +122 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +31 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/hubspot-areas.d.ts +10 -0
- package/dist/src/hubspot-areas.js +114 -0
- package/dist/src/hubspot-budget.d.ts +58 -0
- package/dist/src/hubspot-budget.js +176 -0
- package/dist/src/hubspot-capabilities.d.ts +3 -0
- package/dist/src/hubspot-capabilities.js +1588 -0
- package/dist/src/hubspot-conformance.d.ts +16 -0
- package/dist/src/hubspot-conformance.js +523 -0
- package/dist/src/hubspot-connector.d.ts +125 -0
- package/dist/src/hubspot-connector.js +390 -0
- package/dist/src/hubspot-deferred-capabilities.d.ts +6 -0
- package/dist/src/hubspot-deferred-capabilities.js +64 -0
- package/dist/src/hubspot-mirror-ui.d.ts +62 -0
- package/dist/src/hubspot-mirror-ui.js +152 -0
- package/dist/src/hubspot-oauth.d.ts +8 -0
- package/dist/src/hubspot-oauth.js +291 -0
- package/dist/src/hubspot-server.d.ts +24 -0
- package/dist/src/hubspot-server.js +116 -0
- package/dist/src/hubspot-twin.d.ts +65 -0
- package/dist/src/hubspot-twin.js +1558 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +94 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +68 -0
- package/dist/src/portal.d.ts +20 -0
- package/dist/src/portal.js +30 -0
- package/dist/src/screens/account.d.ts +1 -0
- package/dist/src/screens/account.js +139 -0
- package/dist/src/screens/crm.d.ts +2 -0
- package/dist/src/screens/crm.js +153 -0
- package/dist/src/screens/developer.d.ts +4 -0
- package/dist/src/screens/developer.js +191 -0
- package/dist/src/screens/forms.d.ts +5 -0
- package/dist/src/screens/forms.js +126 -0
- package/dist/src/screens/page.d.ts +21 -0
- package/dist/src/screens/page.js +49 -0
- package/dist/src/screens/session.d.ts +1 -0
- package/dist/src/screens/session.js +32 -0
- package/dist/src/semantics/crm.d.ts +8 -0
- package/dist/src/semantics/crm.js +101 -0
- package/dist/src/webhooks.d.ts +12 -0
- package/dist/src/webhooks.js +77 -0
- package/package.json +75 -0
- package/src/accounts.ts +127 -0
- package/src/cli.ts +29 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/hubspot-areas.ts +155 -0
- package/src/hubspot-budget.ts +202 -0
- package/src/hubspot-capabilities.ts +1523 -0
- package/src/hubspot-conformance.ts +537 -0
- package/src/hubspot-connector.ts +419 -0
- package/src/hubspot-deferred-capabilities.ts +99 -0
- package/src/hubspot-journey.uitest.ts +104 -0
- package/src/hubspot-mirror-ui.ts +166 -0
- package/src/hubspot-oauth.tsx +296 -0
- package/src/hubspot-server.ts +115 -0
- package/src/hubspot-twin.ts +1534 -0
- package/src/index.ts +152 -0
- package/src/manifest.ts +96 -0
- package/src/portal.ts +40 -0
- package/src/screens/account.tsx +129 -0
- package/src/screens/crm.tsx +154 -0
- package/src/screens/developer.tsx +181 -0
- package/src/screens/forms.tsx +117 -0
- package/src/screens/page.tsx +55 -0
- package/src/screens/session.tsx +36 -0
- package/src/semantics/crm.ts +116 -0
- package/src/webhooks.ts +80 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
|
|
2
|
+
import { HubspotBudget, type HubspotBudgetOptions } from './hubspot-budget.js';
|
|
3
|
+
import { type ObjectType } from './hubspot-twin.js';
|
|
4
|
+
/** The HubSpot error envelope, as the vendor documents it. */
|
|
5
|
+
export type HubspotErrorBody = {
|
|
6
|
+
status?: string;
|
|
7
|
+
message?: string;
|
|
8
|
+
category?: string;
|
|
9
|
+
correlationId?: string;
|
|
10
|
+
errors?: unknown[];
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The injected real-HubSpot boundary. `execute` issues ONE HubSpot REST call and returns the
|
|
14
|
+
* parsed JSON body together with the HTTP status, because HubSpot signals refusal with the STATUS
|
|
15
|
+
* (a 4xx carrying `{status:'error', …}`), and a body-only contract cannot tell a refusal from an
|
|
16
|
+
* empty account. A real client (a raw fetch wrapper) is structurally assignable; tests pass a fake.
|
|
17
|
+
*/
|
|
18
|
+
export type HubspotExecute = (method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', path: string, body?: Record<string, unknown>) => Promise<{
|
|
19
|
+
httpStatus: number;
|
|
20
|
+
body: unknown;
|
|
21
|
+
}>;
|
|
22
|
+
/** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
|
|
23
|
+
export type LiveHubspotOptions = {
|
|
24
|
+
/** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
|
|
25
|
+
fetchImpl?: typeof fetch;
|
|
26
|
+
/** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
|
|
27
|
+
budget?: HubspotBudget;
|
|
28
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
29
|
+
budgetOptions?: HubspotBudgetOptions;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* A live executor against the real HubSpot CRM API (the operator's own private-app token).
|
|
33
|
+
*
|
|
34
|
+
* THIS IS THE ONE PLACE this pack issues a live `api.hubapi.com` request, and therefore the one
|
|
35
|
+
* place the rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE
|
|
36
|
+
* the request goes out (`checkBudget`, which THROWS `HubspotBudgetError` instead of returning when
|
|
37
|
+
* the ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a
|
|
38
|
+
* `Retry-After` / 429 / `X-HubSpot-RateLimit-Remaining: 0` signal becomes a persisted cooldown that
|
|
39
|
+
* makes every later call fail fast WITHOUT touching HubSpot. There is deliberately no OPTION to
|
|
40
|
+
* disable the guard, and no value a caller can pass for `budget` that yields an unguarded client.
|
|
41
|
+
* That is NOT immunity from a caller who WANTS one: a fresh `budgetOptions.path` per construction,
|
|
42
|
+
* or an injected clock, restores the allowance, because the seam tests need cannot be denied to a
|
|
43
|
+
* determined caller in the same process. See `hubspot-budget.ts` and the kernel's own header.
|
|
44
|
+
*/
|
|
45
|
+
export declare function liveHubspotExecute(accessToken: string, base?: string, opts?: LiveHubspotOptions): HubspotExecute;
|
|
46
|
+
/**
|
|
47
|
+
* Map a HubSpot SimplePublicObject → a SyncResource the kernel can fold.
|
|
48
|
+
*
|
|
49
|
+
* The renames are load-bearing, not cosmetic: the kernel's META set (`control-plane/src/
|
|
50
|
+
* actions.ts`) silently DROPS resource fields named `type`, `id` or `updatedAt`, and HubSpot's
|
|
51
|
+
* record carries all three. A field literally named `id` would vanish from the projection with no
|
|
52
|
+
* error at all.
|
|
53
|
+
*
|
|
54
|
+
* `hsLocalMint: false` is equally load-bearing. The twin's own create handler stamps
|
|
55
|
+
* `hsLocalMint: true`, and `hubspotRequestForAction` reads that stamp to decide whether a record
|
|
56
|
+
* id addresses anything in the real portal. `observeResources` MERGES observed fields onto an existing
|
|
57
|
+
* subject, so if a portal record ever landed on a subject a local create had already minted, an
|
|
58
|
+
* absent stamp here would leave the stale `true` in place — and the now-genuinely-real record
|
|
59
|
+
* would be permanently unaddressable, pushing every later edit as a duplicate CREATE. Stamping it
|
|
60
|
+
* false makes the OBSERVED row win. (`HUBSPOT_LOCAL_ID_BASE` is what makes that collision
|
|
61
|
+
* implausible in the first place; this is the belt to its braces.)
|
|
62
|
+
*/
|
|
63
|
+
export declare function mapCrmObject(objectType: ObjectType, o: Record<string, unknown>): SyncResource;
|
|
64
|
+
/**
|
|
65
|
+
* The properties a pull ASKS FOR, per object type.
|
|
66
|
+
*
|
|
67
|
+
* HubSpot's list endpoints return only a small default property set unless `?properties=` names
|
|
68
|
+
* more, so a pull that omitted it would land records with near-empty property maps against a real
|
|
69
|
+
* portal — while a fixture handing back rich properties would hide that. These are the
|
|
70
|
+
* HubSpot-defined names the twin models for each type; the portal's CUSTOM properties still need
|
|
71
|
+
* the schema pull filed as `hubspot.connector.pull_properties`.
|
|
72
|
+
*/
|
|
73
|
+
export declare const PULL_PROPERTIES: Record<ObjectType, string[]>;
|
|
74
|
+
/** Map a HubSpot PublicOwner → a SyncResource. */
|
|
75
|
+
export declare function mapOwner(o: Record<string, unknown>): SyncResource;
|
|
76
|
+
export declare function pollTimestamp(): string;
|
|
77
|
+
/** PULL one CRM object type into the twin's observed log (idempotent). */
|
|
78
|
+
export declare function pullHubspotObjects(execute: HubspotExecute, objectType: ObjectType, root?: string, occurredAt?: string): Promise<number>;
|
|
79
|
+
/** PULL the portal's owners into the twin's observed log (idempotent). */
|
|
80
|
+
export declare function pullHubspotOwners(execute: HubspotExecute, root?: string, occurredAt?: string): Promise<number>;
|
|
81
|
+
/** Full pull: all four standard CRM object types + owners, folded record-type by record-type. */
|
|
82
|
+
export declare function pullHubspotAll(execute: HubspotExecute, root?: string, occurredAt?: string): Promise<Record<string, number>>;
|
|
83
|
+
/**
|
|
84
|
+
* D7 consumer-facing pull entry point: pull from real HubSpot (the four standard CRM objects +
|
|
85
|
+
* owners) and fold into the twin in ONE observation, returning the standard
|
|
86
|
+
* `{ observed, deltasAppended }` result. Idempotent — a re-pull of identical state appends
|
|
87
|
+
* nothing (deltasAppended drops to 0).
|
|
88
|
+
*/
|
|
89
|
+
export declare function syncHubspotFromReal(execute: HubspotExecute, opts?: {
|
|
90
|
+
root?: string;
|
|
91
|
+
occurredAt?: string;
|
|
92
|
+
}): Promise<{
|
|
93
|
+
observed: number;
|
|
94
|
+
deltasAppended: number;
|
|
95
|
+
}>;
|
|
96
|
+
/** Translate one pending local action into the real HubSpot REST call it represents, or null. */
|
|
97
|
+
export declare function hubspotRequestForAction(action: TwinAction): {
|
|
98
|
+
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
|
99
|
+
path: string;
|
|
100
|
+
body?: Record<string, unknown>;
|
|
101
|
+
} | null;
|
|
102
|
+
/** PUSH every pending local action to the real portal; confirm each on success. */
|
|
103
|
+
export declare function pushPendingHubspotActions(execute: HubspotExecute, root?: string, occurredAt?: string): Promise<number>;
|
|
104
|
+
/** A `HubspotExecute` over the kernel's executor. At a REAL boundary the kernel sets the sealed
|
|
105
|
+
* credential over these headers (executor.ts); at the twin's own wire any credential is one. */
|
|
106
|
+
export declare function hubspotExecuteOver(execute: RemoteExecute): HubspotExecute;
|
|
107
|
+
/** The refresh adapter: HubSpot enumerates every CRM object type this twin holds, so the whole
|
|
108
|
+
* portal comes back without the world having to say what it has. */
|
|
109
|
+
export declare function syncHubspotFromRemote(execute: RemoteExecute, opts?: {
|
|
110
|
+
root?: string;
|
|
111
|
+
origin?: string;
|
|
112
|
+
occurredAt?: string;
|
|
113
|
+
}): Promise<{
|
|
114
|
+
observed: number;
|
|
115
|
+
deltasAppended: number;
|
|
116
|
+
}>;
|
|
117
|
+
/**
|
|
118
|
+
* The perform adapter.
|
|
119
|
+
*
|
|
120
|
+
* `hubspotRequestForAction` already carries this pack's hard-won rule: a record stamped
|
|
121
|
+
* `hsLocalMint` has never been seen by the portal, so it CREATES rather than patching an id that
|
|
122
|
+
* account does not have. Protocol 2 adds the other half — `ctx.resolve` gives the id HubSpot minted
|
|
123
|
+
* for a subject this world already deployed, so the patch addresses the real record.
|
|
124
|
+
*/
|
|
125
|
+
export declare function performHubspotAction(execute: RemoteExecute, action: TwinAction, ctx: PerformContext): Promise<PushOutcome>;
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// HubSpot CONNECTOR — the live-vendor pull/push path that gives the HubSpot twin the full
|
|
2
|
+
// "git for SaaS" lifecycle.
|
|
3
|
+
//
|
|
4
|
+
// PULL (real → twin): page real CRM records (contacts, companies, deals, tickets) plus the
|
|
5
|
+
// portal's owners out of api.hubapi.com, map them → SyncResource[], and
|
|
6
|
+
// fold into the observed-event log via observeResources (the kernel dedupes — a
|
|
7
|
+
// re-pull of identical state appends nothing).
|
|
8
|
+
// PUSH (twin → real): for every PENDING local action, call the real HubSpot CRM API and
|
|
9
|
+
// confirmAction on success.
|
|
10
|
+
//
|
|
11
|
+
// The vendor I/O is an INJECTED executor (the auth boundary): the kernel and this pack hold NO
|
|
12
|
+
// HubSpot token and import NO network client. Offline/tests pass a fake executor; live runs pass
|
|
13
|
+
// `liveHubspotExecute(accessToken)`. Same code path either way.
|
|
14
|
+
import { assertBudgetGuardIntact, confirmAction, deployableEntries, observeResources } from '@volter/world-core';
|
|
15
|
+
import { HubspotBudget, HubspotBudgetError, hubspotCallWeight } from "./hubspot-budget.js";
|
|
16
|
+
import { OBJECT_TYPES } from "./hubspot-twin.js";
|
|
17
|
+
const SERVICE = 'hubspot';
|
|
18
|
+
/** Subject types that are twin-internal state and are never pushed to a real portal. */
|
|
19
|
+
const INTERNAL_SUBJECT_TYPES = new Set(['owner']);
|
|
20
|
+
/**
|
|
21
|
+
* A live executor against the real HubSpot CRM API (the operator's own private-app token).
|
|
22
|
+
*
|
|
23
|
+
* THIS IS THE ONE PLACE this pack issues a live `api.hubapi.com` request, and therefore the one
|
|
24
|
+
* place the rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE
|
|
25
|
+
* the request goes out (`checkBudget`, which THROWS `HubspotBudgetError` instead of returning when
|
|
26
|
+
* the ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a
|
|
27
|
+
* `Retry-After` / 429 / `X-HubSpot-RateLimit-Remaining: 0` signal becomes a persisted cooldown that
|
|
28
|
+
* makes every later call fail fast WITHOUT touching HubSpot. There is deliberately no OPTION to
|
|
29
|
+
* disable the guard, and no value a caller can pass for `budget` that yields an unguarded client.
|
|
30
|
+
* That is NOT immunity from a caller who WANTS one: a fresh `budgetOptions.path` per construction,
|
|
31
|
+
* or an injected clock, restores the allowance, because the seam tests need cannot be denied to a
|
|
32
|
+
* determined caller in the same process. See `hubspot-budget.ts` and the kernel's own header.
|
|
33
|
+
*/
|
|
34
|
+
export function liveHubspotExecute(accessToken, base = 'https://api.hubapi.com', opts = {}) {
|
|
35
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
36
|
+
// `null`/`undefined` (or omitting it) build the default budget. Anything else must be an
|
|
37
|
+
// UNMODIFIED HubspotBudget: a duck-typed stand-in, a SUBCLASS that overrides `checkBudget`, and
|
|
38
|
+
// a Proxy that traps it are all refused, because all three are one-liners that would otherwise
|
|
39
|
+
// hand back a client with no ceiling at all. The default ledger is keyed by a hash of THIS
|
|
40
|
+
// token — HubSpot limits per app/token, so a cwd-scoped ledger would hand the same token a fresh
|
|
41
|
+
// allowance per checkout/worktree/CI leg.
|
|
42
|
+
const budget = opts.budget !== undefined && opts.budget !== null
|
|
43
|
+
? assertBudgetGuardIntact(opts.budget, HubspotBudget, 'liveHubspotExecute')
|
|
44
|
+
: new HubspotBudget({ token: accessToken, ...(opts.budgetOptions ?? {}) });
|
|
45
|
+
return async (method, path, body) => {
|
|
46
|
+
const headers = { Authorization: `Bearer ${accessToken}` };
|
|
47
|
+
const init = { method, headers };
|
|
48
|
+
if (method !== 'GET' && body !== undefined) {
|
|
49
|
+
headers['Content-Type'] = 'application/json';
|
|
50
|
+
init.body = JSON.stringify(body);
|
|
51
|
+
}
|
|
52
|
+
const weight = hubspotCallWeight(method, path);
|
|
53
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
54
|
+
const reservation = budget.checkBudget(weight);
|
|
55
|
+
const res = await doFetch(`${base}${path}`, init);
|
|
56
|
+
const resHeaders = {};
|
|
57
|
+
res.headers.forEach((v, k) => { resHeaders[k.toLowerCase()] = v; });
|
|
58
|
+
const text = await res.text();
|
|
59
|
+
let parsed = null;
|
|
60
|
+
if (text) {
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(text);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
parsed = { status: 'error', message: text };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
|
|
69
|
+
// `Retry-After` beyond the cap is not something to sleep off) — the cooldown is persisted
|
|
70
|
+
// first either way, so the refusal survives the throw.
|
|
71
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
|
|
72
|
+
// call that louder refusal wins; an answer HubSpot ACCEPTED is kept, so a write that landed is
|
|
73
|
+
// never recorded as failed and performed again on retry.
|
|
74
|
+
try {
|
|
75
|
+
budget.recordCall(weight, resHeaders, { status: res.status, reservation });
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (!(error instanceof HubspotBudgetError) || !res.ok)
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
return { httpStatus: res.status, body: parsed };
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// ── mapping ────────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
/**
|
|
86
|
+
* Map a HubSpot SimplePublicObject → a SyncResource the kernel can fold.
|
|
87
|
+
*
|
|
88
|
+
* The renames are load-bearing, not cosmetic: the kernel's META set (`control-plane/src/
|
|
89
|
+
* actions.ts`) silently DROPS resource fields named `type`, `id` or `updatedAt`, and HubSpot's
|
|
90
|
+
* record carries all three. A field literally named `id` would vanish from the projection with no
|
|
91
|
+
* error at all.
|
|
92
|
+
*
|
|
93
|
+
* `hsLocalMint: false` is equally load-bearing. The twin's own create handler stamps
|
|
94
|
+
* `hsLocalMint: true`, and `hubspotRequestForAction` reads that stamp to decide whether a record
|
|
95
|
+
* id addresses anything in the real portal. `observeResources` MERGES observed fields onto an existing
|
|
96
|
+
* subject, so if a portal record ever landed on a subject a local create had already minted, an
|
|
97
|
+
* absent stamp here would leave the stale `true` in place — and the now-genuinely-real record
|
|
98
|
+
* would be permanently unaddressable, pushing every later edit as a duplicate CREATE. Stamping it
|
|
99
|
+
* false makes the OBSERVED row win. (`HUBSPOT_LOCAL_ID_BASE` is what makes that collision
|
|
100
|
+
* implausible in the first place; this is the belt to its braces.)
|
|
101
|
+
*/
|
|
102
|
+
export function mapCrmObject(objectType, o) {
|
|
103
|
+
return {
|
|
104
|
+
type: 'crm_object',
|
|
105
|
+
id: `${objectType}_${String(o.id)}`,
|
|
106
|
+
fields: {
|
|
107
|
+
objectType,
|
|
108
|
+
hsId: String(o.id),
|
|
109
|
+
hsCreatedAt: String(o.createdAt ?? ''),
|
|
110
|
+
hsUpdatedAt: String(o.updatedAt ?? ''),
|
|
111
|
+
archived: o.archived === true,
|
|
112
|
+
hsLocalMint: false,
|
|
113
|
+
props: (o.properties ?? {}),
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The properties a pull ASKS FOR, per object type.
|
|
119
|
+
*
|
|
120
|
+
* HubSpot's list endpoints return only a small default property set unless `?properties=` names
|
|
121
|
+
* more, so a pull that omitted it would land records with near-empty property maps against a real
|
|
122
|
+
* portal — while a fixture handing back rich properties would hide that. These are the
|
|
123
|
+
* HubSpot-defined names the twin models for each type; the portal's CUSTOM properties still need
|
|
124
|
+
* the schema pull filed as `hubspot.connector.pull_properties`.
|
|
125
|
+
*/
|
|
126
|
+
export const PULL_PROPERTIES = {
|
|
127
|
+
contacts: ['email', 'firstname', 'lastname', 'phone', 'company', 'lifecyclestage', 'hubspot_owner_id', 'createdate', 'lastmodifieddate', 'hs_object_id'],
|
|
128
|
+
companies: ['name', 'domain', 'city', 'industry', 'phone', 'hubspot_owner_id', 'createdate', 'hs_lastmodifieddate', 'hs_object_id'],
|
|
129
|
+
deals: ['dealname', 'amount', 'dealstage', 'pipeline', 'closedate', 'hubspot_owner_id', 'createdate', 'hs_lastmodifieddate', 'hs_object_id'],
|
|
130
|
+
tickets: ['subject', 'content', 'hs_pipeline', 'hs_pipeline_stage', 'hs_ticket_priority', 'hubspot_owner_id', 'createdate', 'hs_lastmodifieddate', 'hs_object_id'],
|
|
131
|
+
};
|
|
132
|
+
/** Map a HubSpot PublicOwner → a SyncResource. */
|
|
133
|
+
export function mapOwner(o) {
|
|
134
|
+
return {
|
|
135
|
+
type: 'owner',
|
|
136
|
+
id: `owner_${String(o.id)}`,
|
|
137
|
+
fields: {
|
|
138
|
+
ownerId: String(o.id), email: o.email ?? null, firstName: o.firstName ?? null,
|
|
139
|
+
lastName: o.lastName ?? null, userId: o.userId ?? null, ownerType: o.type ?? 'PERSON',
|
|
140
|
+
archived: o.archived === true, teams: o.teams ?? [],
|
|
141
|
+
hsCreatedAt: String(o.createdAt ?? ''), hsUpdatedAt: String(o.updatedAt ?? ''),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A STRICTLY INCREASING poll timestamp — the connector's `occurredAt` default.
|
|
147
|
+
*
|
|
148
|
+
* NOT a pinned constant, deliberately. The kernel hashes an observed event over
|
|
149
|
+
* (`occurredAt` + post-state), so under a fixed poll time a vendor value that REVERTS
|
|
150
|
+
* (A → B → A across polls) collides with its own earlier observation: `observeResources` reports
|
|
151
|
+
* `deltasAppended: 1` while nothing lands, and the projection keeps serving the stale value.
|
|
152
|
+
* Forced strictly increasing within the process so two polls in the same millisecond still get
|
|
153
|
+
* distinct stamps (`dynadot-connector.ts` is the precedent).
|
|
154
|
+
*/
|
|
155
|
+
let lastPollMs = 0;
|
|
156
|
+
export function pollTimestamp() {
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
lastPollMs = now > lastPollMs ? now : lastPollMs + 1;
|
|
159
|
+
return new Date(lastPollMs).toISOString();
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Read one vendor reply, or THROW.
|
|
163
|
+
*
|
|
164
|
+
* A refused pull is NOT an empty account: a connector that maps a failure to an empty resource
|
|
165
|
+
* list hands the kernel an empty account to fold OVER real observed state, silently deleting it.
|
|
166
|
+
* HubSpot answers a refusal with a 4xx/5xx AND a `{status:'error', category, message}` body, so
|
|
167
|
+
* both are checked — a body-only check would miss a bare 401 and a status-only check would miss
|
|
168
|
+
* an error envelope that arrived with a 2xx.
|
|
169
|
+
*/
|
|
170
|
+
function unwrap(res, what) {
|
|
171
|
+
const body = (res.body ?? {});
|
|
172
|
+
if (res.httpStatus < 200 || res.httpStatus >= 300) {
|
|
173
|
+
throw new Error(`hubspot ${what} refused: HTTP ${res.httpStatus} ${body.category ?? ''} ${body.message ?? ''}`.trim());
|
|
174
|
+
}
|
|
175
|
+
if (body.status === 'error') {
|
|
176
|
+
throw new Error(`hubspot ${what} refused: ${body.category ?? 'error'} ${body.message ?? ''}`.trim());
|
|
177
|
+
}
|
|
178
|
+
return body;
|
|
179
|
+
}
|
|
180
|
+
/** Page through one CRM object type, following `paging.next.after` until it is absent. */
|
|
181
|
+
async function collectObjects(execute, objectType, pageSize = 100) {
|
|
182
|
+
const out = [];
|
|
183
|
+
let after = null;
|
|
184
|
+
// Bounded so a vendor (or a fake) that never stops advancing cannot loop forever.
|
|
185
|
+
for (let page = 0; page < 100; page += 1) {
|
|
186
|
+
const qs = `limit=${pageSize}&properties=${encodeURIComponent(PULL_PROPERTIES[objectType].join(','))}${after === null ? '' : `&after=${encodeURIComponent(after)}`}`;
|
|
187
|
+
const body = unwrap(await execute('GET', `/crm/v3/objects/${objectType}?${qs}`), `pull ${objectType}`);
|
|
188
|
+
const results = Array.isArray(body.results) ? body.results : [];
|
|
189
|
+
for (const r of results)
|
|
190
|
+
out.push(mapCrmObject(objectType, r));
|
|
191
|
+
const paging = body.paging;
|
|
192
|
+
const next = paging?.next?.after;
|
|
193
|
+
if (next === undefined || next === null || String(next) === after)
|
|
194
|
+
break;
|
|
195
|
+
after = String(next);
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
/** Read the portal's owners. */
|
|
200
|
+
async function collectOwners(execute) {
|
|
201
|
+
const body = unwrap(await execute('GET', '/crm/v3/owners'), 'pull owners');
|
|
202
|
+
const results = Array.isArray(body.results) ? body.results : [];
|
|
203
|
+
return results.map(mapOwner);
|
|
204
|
+
}
|
|
205
|
+
/** PULL one CRM object type into the twin's observed log (idempotent). */
|
|
206
|
+
export async function pullHubspotObjects(execute, objectType, root, occurredAt) {
|
|
207
|
+
const resources = await collectObjects(execute, objectType);
|
|
208
|
+
((__at) => observeResources(SERVICE, resources, { ...(root !== undefined ? { root } : {}), at: __at, batch: `obs:${SERVICE}:${__at}` }))(occurredAt ?? pollTimestamp());
|
|
209
|
+
return resources.length;
|
|
210
|
+
}
|
|
211
|
+
/** PULL the portal's owners into the twin's observed log (idempotent). */
|
|
212
|
+
export async function pullHubspotOwners(execute, root, occurredAt) {
|
|
213
|
+
const resources = await collectOwners(execute);
|
|
214
|
+
((__at) => observeResources(SERVICE, resources, { ...(root !== undefined ? { root } : {}), at: __at, batch: `obs:${SERVICE}:${__at}` }))(occurredAt ?? pollTimestamp());
|
|
215
|
+
return resources.length;
|
|
216
|
+
}
|
|
217
|
+
/** Full pull: all four standard CRM object types + owners, folded record-type by record-type. */
|
|
218
|
+
export async function pullHubspotAll(execute, root, occurredAt) {
|
|
219
|
+
const at = occurredAt ?? pollTimestamp();
|
|
220
|
+
const counts = {};
|
|
221
|
+
for (const objectType of OBJECT_TYPES)
|
|
222
|
+
counts[objectType] = await pullHubspotObjects(execute, objectType, root, at);
|
|
223
|
+
counts.owners = await pullHubspotOwners(execute, root, at);
|
|
224
|
+
return counts;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* D7 consumer-facing pull entry point: pull from real HubSpot (the four standard CRM objects +
|
|
228
|
+
* owners) and fold into the twin in ONE observation, returning the standard
|
|
229
|
+
* `{ observed, deltasAppended }` result. Idempotent — a re-pull of identical state appends
|
|
230
|
+
* nothing (deltasAppended drops to 0).
|
|
231
|
+
*/
|
|
232
|
+
export async function syncHubspotFromReal(execute, opts = {}) {
|
|
233
|
+
const occurredAt = opts.occurredAt ?? pollTimestamp();
|
|
234
|
+
const resources = [];
|
|
235
|
+
for (const objectType of OBJECT_TYPES)
|
|
236
|
+
resources.push(...(await collectObjects(execute, objectType)));
|
|
237
|
+
resources.push(...(await collectOwners(execute)));
|
|
238
|
+
const result = ((__at) => observeResources(SERVICE, resources, { ...(opts.root !== undefined ? { root: opts.root } : {}), at: __at, batch: `obs:${SERVICE}:${__at}` }))(occurredAt);
|
|
239
|
+
return { observed: resources.length, deltasAppended: result.appended };
|
|
240
|
+
}
|
|
241
|
+
// ── push ───────────────────────────────────────────────────────────────────────────────────
|
|
242
|
+
/** Translate one pending local action into the real HubSpot REST call it represents, or null. */
|
|
243
|
+
export function hubspotRequestForAction(action) {
|
|
244
|
+
if (INTERNAL_SUBJECT_TYPES.has(action.subject.type))
|
|
245
|
+
return null;
|
|
246
|
+
const fields = (action.fields ?? {});
|
|
247
|
+
if (action.subject.type === 'crm_object') {
|
|
248
|
+
const objectType = String(fields.objectType ?? '');
|
|
249
|
+
if (!OBJECT_TYPES.includes(objectType))
|
|
250
|
+
return null;
|
|
251
|
+
const hsId = fields.hsId === undefined ? null : String(fields.hsId);
|
|
252
|
+
const props = (fields.props ?? {});
|
|
253
|
+
// A LOCALLY MINTED id must never be sent to the real portal as a real record id. The twin
|
|
254
|
+
// mints from `HUBSPOT_LOCAL_ID_BASE` (9e11) upward — an order of magnitude above the largest
|
|
255
|
+
// HubSpot record id this repo has observed — so such an id addresses NOTHING in a real
|
|
256
|
+
// account. A local create is therefore a CREATE upstream, and only a record whose id came
|
|
257
|
+
// from the real portal may be addressed by id. The two sides are told apart by an explicit
|
|
258
|
+
// stamp, in both directions: the twin's create handler sets `hsLocalMint: true` and every
|
|
259
|
+
// later write carries it forward, while `mapCrmObject` above sets it FALSE on anything a pull
|
|
260
|
+
// observed. Without this guard the archive branch below would fire
|
|
261
|
+
// `DELETE /crm/v3/objects/<type>/<a twin-minted id>` at the operator's real portal — the
|
|
262
|
+
// exact class of defect groq's round-two review found, one twin-minted id fired at a live
|
|
263
|
+
// account.
|
|
264
|
+
const addressable = fields.hsLocalMint !== true && hsId !== null;
|
|
265
|
+
if (fields.archived === true) {
|
|
266
|
+
if (!addressable)
|
|
267
|
+
return null;
|
|
268
|
+
return { method: 'DELETE', path: `/crm/v3/objects/${objectType}/${hsId}` };
|
|
269
|
+
}
|
|
270
|
+
// Strip the system properties HubSpot mints itself — sending them back is rejected upstream.
|
|
271
|
+
const payload = {};
|
|
272
|
+
for (const [k, v] of Object.entries(props)) {
|
|
273
|
+
if (k === 'hs_object_id' || k === 'createdate' || k === 'lastmodifieddate' || k === 'hs_lastmodifieddate')
|
|
274
|
+
continue;
|
|
275
|
+
payload[k] = v;
|
|
276
|
+
}
|
|
277
|
+
if (addressable)
|
|
278
|
+
return { method: 'PATCH', path: `/crm/v3/objects/${objectType}/${hsId}`, body: { properties: payload } };
|
|
279
|
+
return { method: 'POST', path: `/crm/v3/objects/${objectType}`, body: { properties: payload } };
|
|
280
|
+
}
|
|
281
|
+
if (action.subject.type === 'property') {
|
|
282
|
+
const objectType = String(fields.objectType ?? '');
|
|
283
|
+
const name = String(fields.name ?? '');
|
|
284
|
+
if (!objectType || !name)
|
|
285
|
+
return null;
|
|
286
|
+
if (fields.archived === true)
|
|
287
|
+
return { method: 'DELETE', path: `/crm/v3/properties/${objectType}/${name}` };
|
|
288
|
+
return {
|
|
289
|
+
method: 'POST', path: `/crm/v3/properties/${objectType}`,
|
|
290
|
+
body: { name, label: fields.label, type: fields.propType, fieldType: fields.fieldType, groupName: fields.groupName, description: fields.description ?? '' },
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (action.subject.type === 'property_group') {
|
|
294
|
+
const objectType = String(fields.objectType ?? '');
|
|
295
|
+
const name = String(fields.name ?? '');
|
|
296
|
+
if (!objectType || !name)
|
|
297
|
+
return null;
|
|
298
|
+
if (fields.archived === true)
|
|
299
|
+
return { method: 'DELETE', path: `/crm/v3/properties/${objectType}/groups/${name}` };
|
|
300
|
+
return { method: 'POST', path: `/crm/v3/properties/${objectType}/groups`, body: { name, label: fields.label, displayOrder: fields.displayOrder ?? -1 } };
|
|
301
|
+
}
|
|
302
|
+
if (action.subject.type === 'pipeline') {
|
|
303
|
+
const objectType = String(fields.objectType ?? '');
|
|
304
|
+
if (!objectType)
|
|
305
|
+
return null;
|
|
306
|
+
// Pipeline ids are minted locally (`pipeline_N`) and name nothing upstream, so a local
|
|
307
|
+
// pipeline is only ever a CREATE; an archive of one has no real target to address.
|
|
308
|
+
if (fields.archived === true)
|
|
309
|
+
return null;
|
|
310
|
+
return { method: 'POST', path: `/crm/v3/pipelines/${objectType}`, body: { label: fields.label, displayOrder: fields.displayOrder ?? 0, stages: fields.stages ?? [] } };
|
|
311
|
+
}
|
|
312
|
+
// Associations address BOTH sides by id, so the same locally-minted-id rule applies: a twin
|
|
313
|
+
// association between two locally created records names nothing in the real portal.
|
|
314
|
+
if (action.subject.type === 'association')
|
|
315
|
+
return null;
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
/** PUSH every pending local action to the real portal; confirm each on success. */
|
|
319
|
+
export async function pushPendingHubspotActions(execute, root, occurredAt) {
|
|
320
|
+
const pending = deployableEntries(SERVICE, root);
|
|
321
|
+
const at = occurredAt ?? pollTimestamp();
|
|
322
|
+
let pushed = 0;
|
|
323
|
+
for (const action of pending) {
|
|
324
|
+
const req = hubspotRequestForAction(action);
|
|
325
|
+
// An action with no upstream representation is SKIPPED, never aborted — but it is also never
|
|
326
|
+
// confirmed, so it stays pending and visible rather than silently disappearing.
|
|
327
|
+
if (!req)
|
|
328
|
+
continue;
|
|
329
|
+
const res = await execute(req.method, req.path, req.body);
|
|
330
|
+
const body = (res.body ?? {});
|
|
331
|
+
const succeeded = res.httpStatus >= 200 && res.httpStatus < 300 && body.status !== 'error';
|
|
332
|
+
if (!succeeded)
|
|
333
|
+
continue;
|
|
334
|
+
confirmAction({ service: SERVICE, actionId: action.id, subject: action.subject, fields: (action.fields ?? {}), occurredAt: at, ...(root !== undefined ? { root } : {}) });
|
|
335
|
+
pushed += 1;
|
|
336
|
+
}
|
|
337
|
+
return pushed;
|
|
338
|
+
}
|
|
339
|
+
// ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
|
|
340
|
+
/** A `HubspotExecute` over the kernel's executor. At a REAL boundary the kernel sets the sealed
|
|
341
|
+
* credential over these headers (executor.ts); at the twin's own wire any credential is one. */
|
|
342
|
+
export function hubspotExecuteOver(execute) {
|
|
343
|
+
return async (method, path, body) => {
|
|
344
|
+
const res = await execute({
|
|
345
|
+
method, path,
|
|
346
|
+
headers: { accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer twin' },
|
|
347
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
348
|
+
});
|
|
349
|
+
let parsed = {};
|
|
350
|
+
try {
|
|
351
|
+
parsed = JSON.parse(res.body || '{}');
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
parsed = {};
|
|
355
|
+
}
|
|
356
|
+
return { httpStatus: res.status, body: parsed };
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/** The refresh adapter: HubSpot enumerates every CRM object type this twin holds, so the whole
|
|
360
|
+
* portal comes back without the world having to say what it has. */
|
|
361
|
+
export async function syncHubspotFromRemote(execute, opts = {}) {
|
|
362
|
+
return syncHubspotFromReal(hubspotExecuteOver(execute), {
|
|
363
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
364
|
+
occurredAt: opts.occurredAt ?? pollTimestamp(),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The perform adapter.
|
|
369
|
+
*
|
|
370
|
+
* `hubspotRequestForAction` already carries this pack's hard-won rule: a record stamped
|
|
371
|
+
* `hsLocalMint` has never been seen by the portal, so it CREATES rather than patching an id that
|
|
372
|
+
* account does not have. Protocol 2 adds the other half — `ctx.resolve` gives the id HubSpot minted
|
|
373
|
+
* for a subject this world already deployed, so the patch addresses the real record.
|
|
374
|
+
*/
|
|
375
|
+
export async function performHubspotAction(execute, action, ctx) {
|
|
376
|
+
const req = hubspotRequestForAction(action);
|
|
377
|
+
if (!req) {
|
|
378
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
379
|
+
return { externalId: action.subject.id, data: { performed: false, reason: `${op} has no HubSpot mapping — it is this world's own record` } };
|
|
380
|
+
}
|
|
381
|
+
const resolved = ctx.resolve(action.subject.type, action.subject.id);
|
|
382
|
+
const path = resolved === action.subject.id ? req.path : req.path.replace(new RegExp(`/${action.subject.id}(?=$|[/?])`), `/${resolved}`);
|
|
383
|
+
const answered = await hubspotExecuteOver(execute)(req.method, path, req.body);
|
|
384
|
+
if (answered.httpStatus < 200 || answered.httpStatus >= 300) {
|
|
385
|
+
throw new Error(`hubspot ${req.method} ${path} refused: HTTP ${answered.httpStatus} ${JSON.stringify(answered.body).slice(0, 200)}`);
|
|
386
|
+
}
|
|
387
|
+
const body = (answered.body ?? {});
|
|
388
|
+
const externalId = typeof body.id === 'string' ? body.id : resolved;
|
|
389
|
+
return { externalId, data: body };
|
|
390
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CapabilitySpec } from '@volter/world-tooling';
|
|
2
|
+
/** CRM surface this twin has not built yet — inside the declared scope. */
|
|
3
|
+
export declare const HUBSPOT_UNMODELED_CRM_CAPABILITIES: CapabilitySpec[];
|
|
4
|
+
/** Separate HubSpot PRODUCTS behind the same host — outside this pack's declared CRM scope, and
|
|
5
|
+
* not built here; `hubspot-areas.ts` records why each is a separate product. */
|
|
6
|
+
export declare const HUBSPOT_DEFERRED_AREA_CAPABILITIES: CapabilitySpec[];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Not built yet. A plain todo. */
|
|
2
|
+
const unbuilt = (id, area, title, tier) => ({
|
|
3
|
+
id, area, title, dimension: 'api', tier, expected: 'todo',
|
|
4
|
+
});
|
|
5
|
+
/** CRM surface this twin has not built yet — inside the declared scope. */
|
|
6
|
+
export const HUBSPOT_UNMODELED_CRM_CAPABILITIES = [
|
|
7
|
+
// ── CRM engagements ───────────────────────────────────────────────────────────────────────
|
|
8
|
+
unbuilt('hubspot.engagements.notes', 'crm-engagements', 'POST/GET/PATCH/DELETE /crm/v3/objects/notes — note engagements', 'common'),
|
|
9
|
+
unbuilt('hubspot.engagements.tasks', 'crm-engagements', 'POST/GET/PATCH/DELETE /crm/v3/objects/tasks — task engagements', 'common'),
|
|
10
|
+
unbuilt('hubspot.engagements.calls', 'crm-engagements', 'POST/GET/PATCH/DELETE /crm/v3/objects/calls — call engagements', 'common'),
|
|
11
|
+
unbuilt('hubspot.engagements.emails', 'crm-engagements', 'POST/GET/PATCH/DELETE /crm/v3/objects/emails — logged email engagements', 'common'),
|
|
12
|
+
unbuilt('hubspot.engagements.meetings', 'crm-engagements', 'POST/GET/PATCH/DELETE /crm/v3/objects/meetings — meeting engagements', 'common'),
|
|
13
|
+
unbuilt('hubspot.engagements.leads', 'crm-engagements', 'POST/GET/PATCH/DELETE /crm/v3/objects/leads — the leads object', 'niche'),
|
|
14
|
+
unbuilt('hubspot.engagements.feedback_submissions', 'crm-engagements', 'GET /crm/v3/objects/feedback_submissions — NPS/CSAT submissions (read-only)', 'niche'),
|
|
15
|
+
// ── CRM commerce ──────────────────────────────────────────────────────────────────────────
|
|
16
|
+
unbuilt('hubspot.commerce.products', 'crm-commerce', 'POST/GET/PATCH/DELETE /crm/v3/objects/products — the product library', 'common'),
|
|
17
|
+
unbuilt('hubspot.commerce.line_items', 'crm-commerce', 'POST/GET/PATCH/DELETE /crm/v3/objects/line_items — deal line items', 'common'),
|
|
18
|
+
unbuilt('hubspot.commerce.quotes', 'crm-commerce', 'POST/GET/PATCH/DELETE /crm/v3/objects/quotes — quotes and their approval state', 'niche'),
|
|
19
|
+
unbuilt('hubspot.commerce.invoices', 'crm-commerce', 'GET /crm/v3/objects/invoices — invoices (read-only)', 'niche'),
|
|
20
|
+
unbuilt('hubspot.commerce.deal_splits', 'crm-commerce', 'POST /crm/v3/objects/deals/splits/batch/upsert — deal revenue splits', 'niche'),
|
|
21
|
+
// ── CRM custom object schemas ─────────────────────────────────────────────────────────────
|
|
22
|
+
unbuilt('hubspot.schemas.create', 'crm-schemas', 'POST /crm-object-schemas/v3/schemas — define a custom object type', 'common'),
|
|
23
|
+
unbuilt('hubspot.schemas.list', 'crm-schemas', 'GET /crm-object-schemas/v3/schemas — list the portal\'s object type definitions', 'common'),
|
|
24
|
+
unbuilt('hubspot.schemas.custom_object_records', 'crm-schemas', 'CRUD on /crm/v3/objects/{objectTypeId} records for a custom type (2-3453932 / p{portalId}_name)', 'common'),
|
|
25
|
+
// ── CRM lists ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
unbuilt('hubspot.lists.create', 'crm-lists', 'POST /crm/v3/lists — create a static or dynamic list', 'common'),
|
|
27
|
+
unbuilt('hubspot.lists.memberships', 'crm-lists', 'GET/PUT/DELETE /crm/v3/lists/{listId}/memberships — list membership', 'common'),
|
|
28
|
+
unbuilt('hubspot.lists.search', 'crm-lists', 'POST /crm/v3/lists/search — search list definitions', 'niche'),
|
|
29
|
+
// ── CRM imports / exports ─────────────────────────────────────────────────────────────────
|
|
30
|
+
unbuilt('hubspot.imports.create', 'crm-imports-exports', 'POST /crm/v3/imports — start a bulk CSV import job', 'niche'),
|
|
31
|
+
unbuilt('hubspot.imports.status', 'crm-imports-exports', 'GET /crm/v3/imports/{importId} — poll an import job', 'niche'),
|
|
32
|
+
unbuilt('hubspot.exports.create', 'crm-imports-exports', 'POST /crm/v3/exports/export/async — start a bulk export job', 'niche'),
|
|
33
|
+
// ── CRM extensions ────────────────────────────────────────────────────────────────────────
|
|
34
|
+
unbuilt('hubspot.extensions.cards', 'crm-extensions', 'POST/GET/PATCH/DELETE /crm/v3/extensions/cards-dev/{appId} — CRM card definitions', 'niche'),
|
|
35
|
+
unbuilt('hubspot.extensions.calling', 'crm-extensions', 'GET/POST /crm/v3/extensions/calling/{appId}/settings — calling provider registration', 'niche'),
|
|
36
|
+
unbuilt('hubspot.extensions.videoconferencing', 'crm-extensions', 'PUT /crm/v3/extensions/videoconferencing/settings/{appId}', 'niche'),
|
|
37
|
+
unbuilt('hubspot.extensions.timeline', 'crm-extensions', 'POST /integrators/timeline/v3/events — timeline event ingest + templates', 'niche'),
|
|
38
|
+
];
|
|
39
|
+
/** Separate HubSpot PRODUCTS behind the same host — outside this pack's declared CRM scope, and
|
|
40
|
+
* not built here; `hubspot-areas.ts` records why each is a separate product. */
|
|
41
|
+
export const HUBSPOT_DEFERRED_AREA_CAPABILITIES = [
|
|
42
|
+
// ── CMS ───────────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
unbuilt('hubspot.cms.pages', 'cms', 'CRUD on /cms/v3/pages/site-pages and /landing-pages', 'niche'),
|
|
44
|
+
unbuilt('hubspot.cms.blog_posts', 'cms', 'CRUD on /cms/v3/blogs/posts', 'niche'),
|
|
45
|
+
unbuilt('hubspot.cms.hubdb', 'cms', 'CRUD on /cms/v3/hubdb/tables and their rows', 'niche'),
|
|
46
|
+
unbuilt('hubspot.cms.url_redirects', 'cms', 'CRUD on /cms/v3/url-redirects', 'niche'),
|
|
47
|
+
unbuilt('hubspot.cms.source_code', 'cms', 'GET/PUT/DELETE /cms/v3/source-code/{environment}/content/{path}', 'niche'),
|
|
48
|
+
unbuilt('hubspot.cms.domains', 'cms', 'GET /cms/v3/domains — connected domains', 'niche'),
|
|
49
|
+
// ── Marketing ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
unbuilt('hubspot.marketing.emails', 'marketing', 'CRUD on /marketing/v3/emails — marketing email assets', 'niche'),
|
|
51
|
+
unbuilt('hubspot.marketing.forms', 'marketing', 'CRUD on /marketing/v3/forms — HubSpot forms', 'common'),
|
|
52
|
+
unbuilt('hubspot.marketing.events', 'marketing', 'CRUD on /marketing/v3/marketing-events — marketing event objects', 'niche'),
|
|
53
|
+
unbuilt('hubspot.marketing.transactional_send', 'marketing', 'POST /marketing/v3/transactional/single-email/send — send a real transactional email', 'niche'),
|
|
54
|
+
// ── the remaining families, one representative operation each ─────────────────────────────
|
|
55
|
+
unbuilt('hubspot.automation.custom_actions', 'automation', 'CRUD on /automation/v4/actions/{appId} — custom workflow action definitions', 'niche'),
|
|
56
|
+
unbuilt('hubspot.conversations.visitor_identification', 'conversations', 'POST /conversations/v3/visitor-identification/tokens/create', 'niche'),
|
|
57
|
+
unbuilt('hubspot.files.upload', 'files', 'POST /files/v3/files — upload a file to the file manager', 'niche'),
|
|
58
|
+
unbuilt('hubspot.analytics.send_event', 'analytics-events', 'POST /events/v3/send — send a custom behavioral event', 'niche'),
|
|
59
|
+
unbuilt('hubspot.analytics.event_definitions', 'analytics-events', 'CRUD on /events/v3/event-definitions', 'niche'),
|
|
60
|
+
unbuilt('hubspot.communication_preferences.status', 'communication-preferences', 'GET/POST /communication-preferences/v3/status/email/{emailAddress}', 'niche'),
|
|
61
|
+
unbuilt('hubspot.settings.users', 'settings', 'CRUD on /settings/v3/users — portal user seats', 'niche'),
|
|
62
|
+
unbuilt('hubspot.settings.business_units', 'settings', 'GET /settings/v3/business-units/user/{userId}', 'niche'),
|
|
63
|
+
unbuilt('hubspot.webhooks.subscriptions', 'webhooks', 'CRUD on /webhooks/v3/{appId}/subscriptions — app event subscriptions', 'niche'),
|
|
64
|
+
];
|