@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,191 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// AN ACCOUNT'S APPS — the developer projects an account builds and the pages it manages each app on
|
|
3
|
+
// (https://developers.hubspot.com/docs/apps/developer-platform/build-apps/manage-apps-in-hubspot).
|
|
4
|
+
//
|
|
5
|
+
// - POST /_twin/accounts/:hubId/projects {name, uploadedBy, platformVersion, app, webhooks?}: stands in for `hs project
|
|
6
|
+
// upload`, the HubSpot CLI's upload of a project to the account's developer file system, whose API HubSpot does not
|
|
7
|
+
// document (the CLI's requests are its own: a zip of the project and the intermediate representation
|
|
8
|
+
// @hubspot/project-parsing-lib makes of it, then a build and a deploy it polls). `app` is the project's
|
|
9
|
+
// `app-hsmeta.json` and `webhooks` its `webhooks/*-hsmeta.json`, as the configuration pages define them
|
|
10
|
+
// (https://developers.hubspot.com/docs/apps/developer-platform/build-apps/app-configuration,
|
|
11
|
+
// https://developers.hubspot.com/docs/apps/developer-platform/add-features/configure-webhooks); a second upload of a
|
|
12
|
+
// project updates its app, keeping the app's id, client ID and secret. 201 with the build's app.
|
|
13
|
+
// - /developer-projects/:hubId/project/:name/component/:uid/auth: the app's authentication details, "Your app's
|
|
14
|
+
// *Client ID* and *Client secret* will appear in the *Client credentials* section", and its redirect URLs and scopes.
|
|
15
|
+
// - /developer-projects/:hubId/project/:name/component/:uid/distribution: for an OAuth marketplace app, **Begin
|
|
16
|
+
// publishing** and the Acceptable Use Policy ("Until the AUP is signed, you won't be able to install your app in
|
|
17
|
+
// accounts other than developer test accounts"), then the sample install URL and the installed accounts ("Until your
|
|
18
|
+
// app is reviewed … you'll be limited to 25 installs"); for a static-token app, **Install now**, HubSpot's consent
|
|
19
|
+
// (**Connect app**), and the access token, which **Show** reveals.
|
|
20
|
+
// - POST /developer-projects/:hubId/project/:name/delete: deletes the project and its app.
|
|
21
|
+
//
|
|
22
|
+
// Where the documentation stops and the twin decides: the pages' paths and layout; an app id counts from 30000001; a
|
|
23
|
+
// client ID and secret are UUIDs (the OAuth page's `client_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`), a static token is
|
|
24
|
+
// `pat-na1-` and a UUID (the authentication overview masks one as `***-***-*********-…`); only a super admin of the
|
|
25
|
+
// account may manage its apps; the AUP panel's text is the twin's summary, not HubSpot's policy; no app the World builds
|
|
26
|
+
// is ever reviewed, so every install of a marketplace app is of an unverified app.
|
|
27
|
+
import { accountOf, appsOf, hex, installOf, keep, personOf, stored } from "../accounts.js";
|
|
28
|
+
import { CSS, formOf, page, personFor, portalPage, redirect, who } from "./page.js";
|
|
29
|
+
import { Consent } from '@volter/world-ui';
|
|
30
|
+
const uuid = (seed) => { const h = hex(seed); return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`; };
|
|
31
|
+
const INSTALL_CAP = 25;
|
|
32
|
+
/** The door standing in for `hs project upload`. */
|
|
33
|
+
export async function uploadDoor(hubId, request, root, at) {
|
|
34
|
+
let body = {};
|
|
35
|
+
try {
|
|
36
|
+
body = JSON.parse(await request.text());
|
|
37
|
+
}
|
|
38
|
+
catch { /* refused below */ }
|
|
39
|
+
const refuse = (message) => Response.json({ status: 'error', message, category: 'VALIDATION_ERROR' }, { status: 400 });
|
|
40
|
+
const account = accountOf(hubId, root);
|
|
41
|
+
const uploader = personOf(String(body.uploadedBy ?? ''), root);
|
|
42
|
+
if (!account || account.closed_at)
|
|
43
|
+
return refuse(`no open account ${hubId}`);
|
|
44
|
+
if (!uploader || uploader.hubId !== hubId || uploader.superAdmin !== true)
|
|
45
|
+
return refuse('uploadedBy must be a super admin of the account');
|
|
46
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
47
|
+
const app = body.app;
|
|
48
|
+
const c = app?.config;
|
|
49
|
+
const auth = c?.auth;
|
|
50
|
+
if (!name || !app || app.type !== 'app' || typeof app.uid !== 'string' || !c)
|
|
51
|
+
return refuse('a project name and its app component (app-hsmeta.json: uid, type "app", config) are required');
|
|
52
|
+
if (typeof c.name !== 'string' || typeof c.description !== 'string')
|
|
53
|
+
return refuse('app config requires name and description');
|
|
54
|
+
if (c.distribution !== 'marketplace' && c.distribution !== 'private')
|
|
55
|
+
return refuse('distribution must be marketplace or private');
|
|
56
|
+
if (!auth || (auth.type !== 'oauth' && auth.type !== 'static'))
|
|
57
|
+
return refuse('auth.type must be oauth or static');
|
|
58
|
+
if (c.distribution === 'marketplace' && auth.type !== 'oauth')
|
|
59
|
+
return refuse('a marketplace app must use oauth');
|
|
60
|
+
const redirectUrls = Array.isArray(auth.redirectUrls) ? auth.redirectUrls.map(String) : [];
|
|
61
|
+
// "Each app must have at least one auth redirect URL, and it must use HTTPS. The only exception is that http://localhost is allowed"
|
|
62
|
+
if (auth.type === 'oauth' && (!redirectUrls.length || redirectUrls.some((u) => !u.startsWith('https://') && !u.startsWith('http://localhost'))))
|
|
63
|
+
return refuse('an oauth app needs at least one redirect URL, each HTTPS (or http://localhost)');
|
|
64
|
+
const requiredScopes = Array.isArray(auth.requiredScopes) ? auth.requiredScopes.map(String) : [];
|
|
65
|
+
if (!requiredScopes.length)
|
|
66
|
+
return refuse('an app must include at least one required scope');
|
|
67
|
+
const hooks = body.webhooks;
|
|
68
|
+
const settings = (hooks?.config?.settings ?? {});
|
|
69
|
+
const crmObjects = ((hooks?.config?.subscriptions ?? {}).crmObjects ?? []);
|
|
70
|
+
if (hooks && (hooks.type !== 'webhooks' || typeof settings.targetUrl !== 'string'))
|
|
71
|
+
return refuse('the webhooks component needs type "webhooks" and settings.targetUrl');
|
|
72
|
+
const existing = stored('_app', root).find((a) => a.hubId === hubId && a.project === name && !a.deleted_at);
|
|
73
|
+
const appId = existing ? Number(existing.appId) : 30000000 + stored('_app', root).length + 1;
|
|
74
|
+
const fields = {
|
|
75
|
+
appId, hubId, project: name, uid: app.uid, name: c.name, description: c.description, distribution: c.distribution,
|
|
76
|
+
authType: auth.type, redirectUrls, requiredScopes,
|
|
77
|
+
optionalScopes: Array.isArray(auth.optionalScopes) ? auth.optionalScopes.map(String) : [],
|
|
78
|
+
platformVersion: typeof body.platformVersion === 'string' ? body.platformVersion : '2026.03',
|
|
79
|
+
client_id: existing?.client_id ?? uuid(`hubspot-client:${appId}`),
|
|
80
|
+
client_secret: existing?.client_secret ?? uuid(`hubspot-secret:${appId}:${at}`),
|
|
81
|
+
webhooks: hooks ? {
|
|
82
|
+
targetUrl: settings.targetUrl,
|
|
83
|
+
subscriptions: crmObjects.map((s, i) => ({ id: appId * 100 + i + 1, subscriptionType: s.subscriptionType, objectType: s.objectType, ...(s.propertyName ? { propertyName: s.propertyName } : {}), active: s.active !== false })),
|
|
84
|
+
} : null,
|
|
85
|
+
publishing: existing?.publishing ?? null, aup_signed_at: existing?.aup_signed_at ?? null, aup_signed_by: existing?.aup_signed_by ?? null,
|
|
86
|
+
created_at: existing?.created_at ?? at, uploaded_at: at, uploaded_by: uploader.email, deleted_at: null,
|
|
87
|
+
};
|
|
88
|
+
await keep('_app', `app:${appId}`, fields, root, at, String(uploader.email));
|
|
89
|
+
return Response.json({ project: name, appId, uid: app.uid, url: `/developer-projects/${hubId}/project/${encodeURIComponent(name)}/component/${encodeURIComponent(String(app.uid))}/auth` }, { status: 201 });
|
|
90
|
+
}
|
|
91
|
+
const base = (a) => `/developer-projects/${String(a.hubId)}/project/${encodeURIComponent(String(a.project))}/component/${encodeURIComponent(String(a.uid))}`;
|
|
92
|
+
function authPage(a, account, person) {
|
|
93
|
+
return portalPage(`${String(a.name)} · Auth`, {
|
|
94
|
+
merchant: who(account, person), back: { href: `${base(a)}/distribution`, label: 'Distribution' },
|
|
95
|
+
sections: [
|
|
96
|
+
{ heading: 'Client credentials', items: a.authType === 'oauth'
|
|
97
|
+
? [{ title: 'Client ID', detail: String(a.client_id) }, { title: 'Client secret', detail: String(a.client_secret) }]
|
|
98
|
+
: [], empty: 'A static-token app has no client credentials; its access token is on the Distribution tab.' },
|
|
99
|
+
{ heading: 'Redirect URLs', items: a.redirectUrls.map((u) => ({ title: u })), empty: 'None.' },
|
|
100
|
+
{ heading: 'Scopes', items: [...a.requiredScopes.map((s) => ({ title: s, detail: 'Required' })), ...a.optionalScopes.map((s) => ({ title: s, detail: 'Optional' }))] },
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function installUrl(a) {
|
|
105
|
+
const q = new URLSearchParams({ client_id: String(a.client_id), redirect_uri: a.redirectUrls[0] ?? '', scope: ['oauth', ...a.requiredScopes].join(' ') });
|
|
106
|
+
return `https://app.hubspot.com/oauth/authorize?${q.toString()}`;
|
|
107
|
+
}
|
|
108
|
+
function distributionPage(a, account, person, root, opts = {}) {
|
|
109
|
+
const installs = stored('_install', root).filter((i) => i.appId === a.appId && !i.uninstalled_at);
|
|
110
|
+
const d = `${base(a)}/distribution`;
|
|
111
|
+
if (a.authType === 'static') {
|
|
112
|
+
const install = installOf(Number(a.appId), Number(a.hubId), root);
|
|
113
|
+
return portalPage(`${String(a.name)} · Distribution`, {
|
|
114
|
+
merchant: who(account, person), back: { href: `${base(a)}/auth`, label: 'Auth' }, ...(opts.notice ? { notice: opts.notice } : {}),
|
|
115
|
+
sections: [{ heading: 'Manage distribution', items: [install
|
|
116
|
+
? { title: String(account.name), detail: `Access token: ${opts.show ? String(install.static_token) : `${String(install.static_token).slice(0, 8)}${'•'.repeat(12)}`}`, note: `Installed ${String(install.installed_at).slice(0, 10)}`, actions: opts.show ? [] : [{ label: 'Show', action: `${d}?show=token`, method: 'get' }] }
|
|
117
|
+
: { title: String(account.name), detail: 'Not installed', actions: [{ label: 'Install now', action: `${d}/install` }] }] }],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const published = !!a.publishing;
|
|
121
|
+
const signed = !!a.aup_signed_at;
|
|
122
|
+
return portalPage(`${String(a.name)} · Distribution`, {
|
|
123
|
+
merchant: who(account, person), back: { href: `${base(a)}/auth`, label: 'Auth' }, ...(opts.notice ? { notice: opts.notice } : {}),
|
|
124
|
+
sections: [
|
|
125
|
+
{ heading: 'Distribution', items: [signed
|
|
126
|
+
? { title: 'Unlisted marketplace app', detail: `Install URL: ${installUrl(a)}`, note: `${installs.length} of ${INSTALL_CAP} installs before listing` }
|
|
127
|
+
: { title: 'Not published', detail: 'Until the AUP is signed, you won\'t be able to install your app in accounts other than developer test accounts.', actions: [{ label: published ? 'Continue publishing' : 'Begin publishing', action: `${d}/begin-publishing`, tone: 'primary' }] }] },
|
|
128
|
+
...(opts.aup && !signed ? [{ heading: 'Acceptable Use Policy', items: [{ title: 'HubSpot App Acceptable Use Policy', detail: 'Review and sign the policy to install this app in accounts other than developer test accounts.', actions: [{ label: 'Sign', action: `${d}/aup`, tone: 'primary', fields: { accept: 'yes' } }] }] }] : []),
|
|
129
|
+
{ heading: 'Installed accounts', items: installs.map((i) => ({ title: String(accountOf(Number(i.hubId), root)?.name ?? i.hubId), note: `Installed ${String(i.installed_at).slice(0, 10)}` })), empty: 'No installs yet.' },
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/** app.hubspot.com/developer-projects/…, or undefined for another path. */
|
|
134
|
+
export async function developerPages(request, root, at) {
|
|
135
|
+
const url = new URL(request.url);
|
|
136
|
+
const m = /^\/developer-projects\/(\d+)\/project\/([^/]+)(?:\/component\/([^/]+)\/(auth|distribution)(?:\/(begin-publishing|aup|install|install\/confirm))?|\/(delete))$/.exec(url.pathname.replace(/\/+$/, ''));
|
|
137
|
+
if (!m)
|
|
138
|
+
return undefined;
|
|
139
|
+
const hubId = Number(m[1]);
|
|
140
|
+
const got = personFor(request, hubId, root);
|
|
141
|
+
if (got instanceof Response)
|
|
142
|
+
return got;
|
|
143
|
+
const { person, account } = got;
|
|
144
|
+
if (person.superAdmin !== true)
|
|
145
|
+
return page('HubSpot', CSS.portal, _jsx("main", { className: "portal-main", children: _jsx("h1", { children: "Only a super admin can manage this account's apps." }) }), 403);
|
|
146
|
+
const app = appsOf(hubId, root).find((a) => a.project === decodeURIComponent(m[2]) && (m[3] === undefined || a.uid === decodeURIComponent(m[3])));
|
|
147
|
+
if (!app)
|
|
148
|
+
return page('HubSpot', CSS.portal, _jsx("main", { className: "portal-main", children: _jsx("h1", { children: "This project doesn't exist." }) }), 404);
|
|
149
|
+
const actor = String(person.email);
|
|
150
|
+
if (m[6] === 'delete') {
|
|
151
|
+
if (request.method !== 'POST')
|
|
152
|
+
return undefined;
|
|
153
|
+
await keep('_app', `app:${String(app.appId)}`, { ...app, deleted_at: at, deleted_by: actor }, root, at, actor);
|
|
154
|
+
return portalPage('Projects', { merchant: who(account, person), notice: `Project ${String(app.project)} deleted.`, sections: [] });
|
|
155
|
+
}
|
|
156
|
+
const tab = m[4];
|
|
157
|
+
const act = m[5];
|
|
158
|
+
if (!act) {
|
|
159
|
+
if (request.method !== 'GET')
|
|
160
|
+
return undefined;
|
|
161
|
+
return tab === 'auth' ? authPage(app, account, person) : distributionPage(app, account, person, root, { show: url.searchParams.get('show') === 'token' });
|
|
162
|
+
}
|
|
163
|
+
if (request.method !== 'POST' || tab !== 'distribution')
|
|
164
|
+
return undefined;
|
|
165
|
+
if (act === 'begin-publishing' && app.distribution === 'marketplace') {
|
|
166
|
+
const withPanel = { ...app, publishing: app.publishing ?? at };
|
|
167
|
+
if (!app.publishing)
|
|
168
|
+
await keep('_app', `app:${String(app.appId)}`, withPanel, root, at, actor);
|
|
169
|
+
return distributionPage(withPanel, account, person, root, { aup: true });
|
|
170
|
+
}
|
|
171
|
+
if (act === 'aup' && app.distribution === 'marketplace' && app.publishing) {
|
|
172
|
+
if ((await formOf(request)).accept !== 'yes')
|
|
173
|
+
return distributionPage(app, account, person, root, { aup: true, notice: 'Sign the Acceptable Use Policy to continue.' });
|
|
174
|
+
const signed = { ...app, aup_signed_at: at, aup_signed_by: actor };
|
|
175
|
+
await keep('_app', `app:${String(app.appId)}`, signed, root, at, actor);
|
|
176
|
+
return distributionPage(signed, account, person, root, { notice: 'Acceptable Use Policy signed. You can install this app with its install URL.' });
|
|
177
|
+
}
|
|
178
|
+
if (act === 'install' && app.authType === 'static') {
|
|
179
|
+
return page(`Connect ${String(app.name)}`, CSS.consent, (_jsx(Consent, { heading: `Connect ${String(app.name)} to ${String(account.name)}`, app: { name: String(app.name) }, request: `${String(account.name)} · Hub ID ${hubId}`, permissions: app.requiredScopes.map((s) => ({ title: s, detail: 'Requested scope' })), action: `${base(app)}/distribution/install/confirm`, fields: {}, deny: { name: 'answer', value: 'cancel', label: 'Cancel' }, allow: { name: 'answer', value: 'allow', label: 'Connect app' } })));
|
|
180
|
+
}
|
|
181
|
+
if (act === 'install/confirm' && app.authType === 'static') {
|
|
182
|
+
if ((await formOf(request)).answer !== 'allow')
|
|
183
|
+
return redirect(`${base(app)}/distribution`);
|
|
184
|
+
if (installOf(Number(app.appId), hubId, root))
|
|
185
|
+
return distributionPage(app, account, person, root, { notice: 'This app is already installed.' });
|
|
186
|
+
const token = `pat-na1-${uuid(`hubspot-static:${String(app.appId)}:${at}`)}`;
|
|
187
|
+
await keep('_install', `install:${String(app.appId)}:${hubId}:${at}`, { appId: app.appId, hubId, installed_by: actor, scopes: app.requiredScopes, installed_at: at, uninstalled_at: null, static_token: token }, root, at, actor);
|
|
188
|
+
return redirect(`${base(app)}/distribution`);
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const FORMS_HOST = "api.hsforms.com";
|
|
2
|
+
/** app.hubspot.com/forms/…, or undefined for another path. */
|
|
3
|
+
export declare function formPages(request: Request, root: string | undefined, at: string): Promise<Response | undefined>;
|
|
4
|
+
/** api.hsforms.com's submission, or undefined for another request. */
|
|
5
|
+
export declare function formSubmission(request: Request, root: string | undefined, at: string): Promise<Response | undefined>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// HUBSPOT FORMS — an account's forms, built on its form builder and filled in on a website
|
|
2
|
+
// (https://knowledge.hubspot.com/forms/create-forms; Dub's guide, https://dub.co/docs/integrations/hubspot, "Option 1: Using HubSpot
|
|
3
|
+
// Forms": "In the HubSpot form builder, add a hidden field and map it to the **Dub Id** contact property").
|
|
4
|
+
//
|
|
5
|
+
// - app.hubspot.com/forms/:hubId: the account's forms, and **Create form** with its name (a form starts with Email,
|
|
6
|
+
// First name and Last name, contact fields).
|
|
7
|
+
// - app.hubspot.com/forms/:hubId/editor/:formGuid/edit: the form's fields, and **Add hidden field** mapped to a contact
|
|
8
|
+
// property a form may use (a property with `formField`, as Dub makes its Dub Click ID: `formField: true`, "Allow the
|
|
9
|
+
// property to be used in a HubSpot form").
|
|
10
|
+
// - POST https://api.hsforms.com/submissions/v3/integration/submit/:portalId/:formGuid: a visitor's submission, as HubSpot's
|
|
11
|
+
// Forms API takes it ("Submit data to a form (unauthenticated)",
|
|
12
|
+
// https://developers.hubspot.com/docs/api-reference/legacy/marketing/forms/v3-legacy/submit-data-unauthenticated):
|
|
13
|
+
// `fields`, each `{objectTypeId, name, value}`, and `context`; a field the form does not have is refused
|
|
14
|
+
// (`FIELD_NOT_IN_FORM_DEFINITION`), and an accepted submission answers `inlineMessage`. It makes or updates the
|
|
15
|
+
// contact with that email in the form's account (portal.ts: `FORM`), whose webhook events follow.
|
|
16
|
+
//
|
|
17
|
+
// Where the documentation stops and the twin decides: the builder's paths and layout; a new form's three fields and its
|
|
18
|
+
// thank-you message ("Thanks for submitting the form."); the embed Dub's guide puts on a site (js.hsforms.net) submits
|
|
19
|
+
// through a request HubSpot does not document, so a visitor's submission is the Forms API's documented one; a submission
|
|
20
|
+
// without an email, or with one that is not an address, is refused as `INVALID_EMAIL`; a contact is found by its email
|
|
21
|
+
// to be updated, as HubSpot deduplicates contacts by email.
|
|
22
|
+
import { accountOf, hex, keep, stored } from "../accounts.js";
|
|
23
|
+
import { handleHubspotTwinRequest } from "../hubspot-twin.js";
|
|
24
|
+
import { inPortal } from "../portal.js";
|
|
25
|
+
import { formOf, personFor, portalPage, redirect, who } from "./page.js";
|
|
26
|
+
export const FORMS_HOST = 'api.hsforms.com';
|
|
27
|
+
const DEFAULT_FIELDS = [
|
|
28
|
+
{ name: 'email', label: 'Email', hidden: false },
|
|
29
|
+
{ name: 'firstname', label: 'First name', hidden: false },
|
|
30
|
+
{ name: 'lastname', label: 'Last name', hidden: false },
|
|
31
|
+
];
|
|
32
|
+
const formsOf = (hub, root) => stored('_form', root).filter((f) => f.hubId === hub && !f.archived);
|
|
33
|
+
const uuid = (seed) => { const h = hex(seed); return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`; };
|
|
34
|
+
/** app.hubspot.com/forms/…, or undefined for another path. */
|
|
35
|
+
export async function formPages(request, root, at) {
|
|
36
|
+
const path = new URL(request.url).pathname.replace(/\/+$/, '');
|
|
37
|
+
const m = /^\/forms\/(\d+)(?:\/editor\/([0-9a-f-]+)\/edit)?$/.exec(path);
|
|
38
|
+
if (!m)
|
|
39
|
+
return undefined;
|
|
40
|
+
const hub = Number(m[1]);
|
|
41
|
+
const got = personFor(request, hub, root);
|
|
42
|
+
if (got instanceof Response)
|
|
43
|
+
return got;
|
|
44
|
+
const { person, account } = got;
|
|
45
|
+
const actor = String(person.email);
|
|
46
|
+
if (!m[2]) {
|
|
47
|
+
if (request.method === 'POST') {
|
|
48
|
+
const name = ((await formOf(request)).name ?? '').trim();
|
|
49
|
+
if (!name)
|
|
50
|
+
return listPage(hub, account, person, root, 'A form needs a name.', 422);
|
|
51
|
+
const guid = uuid(`hubspot-form:${hub}:${name}:${at}`);
|
|
52
|
+
await keep('_form', `form:${guid}`, { guid, hubId: hub, name, fields: DEFAULT_FIELDS, created_at: at, created_by: actor, archived: false }, root, at, actor);
|
|
53
|
+
return redirect(`/forms/${hub}/editor/${guid}/edit`);
|
|
54
|
+
}
|
|
55
|
+
return listPage(hub, account, person, root);
|
|
56
|
+
}
|
|
57
|
+
const form = formsOf(hub, root).find((f) => f.guid === m[2]);
|
|
58
|
+
if (!form)
|
|
59
|
+
return portalPage('Forms', { merchant: who(account, person), notice: 'This form does not exist.', sections: [] }, 404);
|
|
60
|
+
const properties = await inPortal({ hub, source: 'CRM_UI' }, () => handleHubspotTwinRequest({ method: 'GET', path: '/crm/v3/properties/contacts', root, occurredAt: at }));
|
|
61
|
+
const usable = (properties.body.results ?? []).filter((p) => p.formField === true && p.archived !== true);
|
|
62
|
+
let notice;
|
|
63
|
+
if (request.method === 'POST') {
|
|
64
|
+
const wanted = (await formOf(request)).property;
|
|
65
|
+
const property = usable.find((p) => p.name === wanted);
|
|
66
|
+
const fields = form.fields;
|
|
67
|
+
if (!property)
|
|
68
|
+
notice = 'Choose a contact property that can be used in forms.';
|
|
69
|
+
else if (fields.some((f) => f.name === property.name))
|
|
70
|
+
notice = `${String(property.label)} is already on this form.`;
|
|
71
|
+
else {
|
|
72
|
+
await keep('_form', `form:${String(form.guid)}`, { ...form, fields: [...fields, { name: property.name, label: property.label, hidden: true }], updated_at: at }, root, at, actor);
|
|
73
|
+
return redirect(`/forms/${hub}/editor/${String(form.guid)}/edit`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return portalPage(String(form.name), { merchant: who(account, person), back: { href: `/forms/${hub}`, label: 'Forms' }, ...(notice ? { notice } : {}),
|
|
77
|
+
sections: [{ heading: 'Fields', items: form.fields.map((f) => ({ title: String(f.label), detail: String(f.name), ...(f.hidden ? { badge: 'Hidden' } : {}) })) },
|
|
78
|
+
{ heading: 'Embed', items: [{ title: 'Form ID', detail: String(form.guid) }, { title: 'Portal ID', detail: String(hub) }] }],
|
|
79
|
+
forms: [{ heading: 'Add hidden field', action: `/forms/${hub}/editor/${String(form.guid)}/edit`, submit: { label: 'Add' },
|
|
80
|
+
fields: [{ id: 'property', label: 'Contact property', options: usable.map((p) => ({ value: String(p.name), label: String(p.label) })) }] }] });
|
|
81
|
+
}
|
|
82
|
+
function listPage(hub, account, person, root, notice, status = 200) {
|
|
83
|
+
return portalPage('Forms', { merchant: who(account, person), ...(notice ? { notice } : {}),
|
|
84
|
+
sections: [{ heading: 'Forms', empty: 'No forms yet.', items: formsOf(hub, root).map((f) => ({ title: String(f.name), detail: String(f.guid), actions: [{ label: 'Edit', action: `/forms/${hub}/editor/${String(f.guid)}/edit`, method: 'get' }] })) }],
|
|
85
|
+
forms: [{ heading: 'Create form', action: `/forms/${hub}`, submit: { label: 'Create form' }, fields: [{ id: 'name', label: 'Form name' }] }] }, status);
|
|
86
|
+
}
|
|
87
|
+
const submitError = (errorType, message) => Response.json({ status: 'error', message: 'The request is not valid', errors: [{ message, errorType }] }, { status: 400 });
|
|
88
|
+
/** api.hsforms.com's submission, or undefined for another request. */
|
|
89
|
+
export async function formSubmission(request, root, at) {
|
|
90
|
+
const m = /^\/submissions\/v3\/integration\/submit\/(\d+)\/([0-9a-f-]+)$/.exec(new URL(request.url).pathname.replace(/\/+$/, ''));
|
|
91
|
+
if (!m || request.method !== 'POST')
|
|
92
|
+
return undefined;
|
|
93
|
+
const hub = Number(m[1]);
|
|
94
|
+
const form = accountOf(hub, root)?.closed_at ? undefined : formsOf(hub, root).find((f) => f.guid === m[2]);
|
|
95
|
+
if (!form)
|
|
96
|
+
return Response.json({ status: 'error', message: 'Form not found', category: 'OBJECT_NOT_FOUND' }, { status: 404 });
|
|
97
|
+
let body = {};
|
|
98
|
+
try {
|
|
99
|
+
body = JSON.parse(await request.text());
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return submitError('INVALID_METADATA', 'The request body is not valid JSON.');
|
|
103
|
+
}
|
|
104
|
+
const fields = Array.isArray(body.fields) ? body.fields : [];
|
|
105
|
+
const names = new Set(form.fields.map((f) => String(f.name)));
|
|
106
|
+
const properties = {};
|
|
107
|
+
for (const f of fields) {
|
|
108
|
+
if (!names.has(String(f.name)))
|
|
109
|
+
return submitError('FIELD_NOT_IN_FORM_DEFINITION', `${String(f.name)} is not a field of this form.`);
|
|
110
|
+
properties[String(f.name)] = String(f.value ?? '');
|
|
111
|
+
}
|
|
112
|
+
const email = (properties.email ?? '').trim().toLowerCase();
|
|
113
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
|
|
114
|
+
return submitError('INVALID_EMAIL', 'Email address is invalid.');
|
|
115
|
+
properties.email = email;
|
|
116
|
+
await inPortal({ hub, source: 'FORM', sourceId: String(form.guid) }, async () => {
|
|
117
|
+
const found = await handleHubspotTwinRequest({ method: 'POST', path: '/crm/v3/objects/contacts/search', body: JSON.stringify({ filterGroups: [{ filters: [{ propertyName: 'email', operator: 'EQ', value: email }] }], limit: 1 }), root, occurredAt: at });
|
|
118
|
+
const id = ((found.body.results ?? [])[0])?.id;
|
|
119
|
+
await handleHubspotTwinRequest(id === undefined
|
|
120
|
+
? { method: 'POST', path: '/crm/v3/objects/contacts', body: JSON.stringify({ properties }), root, occurredAt: at }
|
|
121
|
+
: { method: 'PATCH', path: `/crm/v3/objects/contacts/${String(id)}`, body: JSON.stringify({ properties }), root, occurredAt: at });
|
|
122
|
+
});
|
|
123
|
+
const n = stored('_form_submission', root).length + 1;
|
|
124
|
+
await keep('_form_submission', `submission:${n}`, { n, formGuid: form.guid, hubId: hub, fields, context: body.context ?? null, submitted_at: at }, root, at, email);
|
|
125
|
+
return Response.json({ inlineMessage: 'Thanks for submitting the form.' });
|
|
126
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import { type PortalProps } from '@volter/world-ui';
|
|
3
|
+
import { type Row } from '../accounts.js';
|
|
4
|
+
export declare const SKIN = "\nbody { background: #ffffff; color: #33475b; font-family: \"Lexend Deca\", Helvetica, Arial, sans-serif; }\n.sign-in-mark, .consent-vendor { background: #ff7a59; }\n.sign-in-box, .consent-box { background: #ffffff; border-color: #cbd6e2; }\n.sign-in-error { background: #fdedee; border-color: #f2545b; }\n.sign-in-submit, .consent-allow, .portal-primary { background: #ff7a59; border-color: #ff7a59; color: #ffffff; }\n.consent-badge { background: #f5f8fa; border: 1px solid #cbd6e2; color: #33475b; }\n.consent-link, .consent-actions { border-color: #cbd6e2; }\n.consent-who small, .consent-permission summary small, .consent-note { color: #516f90; }\n.consent-deny { background: #ffffff; border-color: #cbd6e2; color: #33475b; }\n.portal-side { background: #f5f8fa; border-right: 1px solid #cbd6e2; }\n.portal-section h2 { border-color: #cbd6e2; color: #516f90; }\n.portal-notice { background: #e5f5f8; border: 1px solid #7fd1de; word-break: break-all; }\n.portal-button { background: #ffffff; border-color: #cbd6e2; color: #33475b; }\n.portal-danger { color: #d94c53; }\n";
|
|
5
|
+
export declare const CSS: {
|
|
6
|
+
signIn: string[];
|
|
7
|
+
consent: string[];
|
|
8
|
+
portal: string[];
|
|
9
|
+
};
|
|
10
|
+
/** A settings or record page: the account and person on the left, the page's sections and forms. */
|
|
11
|
+
export declare function portalPage(title: string, props: PortalProps, status?: number): Response;
|
|
12
|
+
export declare function page(title: string, css: string[], body: ReactElement, status?: number): Response;
|
|
13
|
+
export declare function redirect(location: string): Response;
|
|
14
|
+
export declare function formOf(request: Request): Promise<Record<string, string>>;
|
|
15
|
+
/** The person a page needs, signed in to the account the page is of; a redirect to the sign-in, or a refusal, if not. */
|
|
16
|
+
export declare function personFor(request: Request, hubId: number | undefined, root?: string): {
|
|
17
|
+
person: Row;
|
|
18
|
+
account: Row;
|
|
19
|
+
} | Response;
|
|
20
|
+
/** The left column every account page carries. */
|
|
21
|
+
export declare const who: (account: Row, person: Row) => string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { CONSENT_CSS, flowPage, Portal, PORTAL_CSS, SIGN_IN_CSS } from '@volter/world-ui';
|
|
3
|
+
import { accountOf, signedIn } from "../accounts.js";
|
|
4
|
+
// HubSpot's skin: its slate ink, light grey panels and orange primary buttons
|
|
5
|
+
export const SKIN = `
|
|
6
|
+
body { background: #ffffff; color: #33475b; font-family: "Lexend Deca", Helvetica, Arial, sans-serif; }
|
|
7
|
+
.sign-in-mark, .consent-vendor { background: #ff7a59; }
|
|
8
|
+
.sign-in-box, .consent-box { background: #ffffff; border-color: #cbd6e2; }
|
|
9
|
+
.sign-in-error { background: #fdedee; border-color: #f2545b; }
|
|
10
|
+
.sign-in-submit, .consent-allow, .portal-primary { background: #ff7a59; border-color: #ff7a59; color: #ffffff; }
|
|
11
|
+
.consent-badge { background: #f5f8fa; border: 1px solid #cbd6e2; color: #33475b; }
|
|
12
|
+
.consent-link, .consent-actions { border-color: #cbd6e2; }
|
|
13
|
+
.consent-who small, .consent-permission summary small, .consent-note { color: #516f90; }
|
|
14
|
+
.consent-deny { background: #ffffff; border-color: #cbd6e2; color: #33475b; }
|
|
15
|
+
.portal-side { background: #f5f8fa; border-right: 1px solid #cbd6e2; }
|
|
16
|
+
.portal-section h2 { border-color: #cbd6e2; color: #516f90; }
|
|
17
|
+
.portal-notice { background: #e5f5f8; border: 1px solid #7fd1de; word-break: break-all; }
|
|
18
|
+
.portal-button { background: #ffffff; border-color: #cbd6e2; color: #33475b; }
|
|
19
|
+
.portal-danger { color: #d94c53; }
|
|
20
|
+
`;
|
|
21
|
+
export const CSS = { signIn: [SIGN_IN_CSS, SKIN], consent: [CONSENT_CSS, SKIN], portal: [PORTAL_CSS, SKIN] };
|
|
22
|
+
/** A settings or record page: the account and person on the left, the page's sections and forms. */
|
|
23
|
+
export function portalPage(title, props, status = 200) {
|
|
24
|
+
return flowPage({ title: `${title} | HubSpot`, css: CSS.portal, status, body: _jsx(Portal, { ...props }) });
|
|
25
|
+
}
|
|
26
|
+
export function page(title, css, body, status = 200) {
|
|
27
|
+
return flowPage({ title: `${title} | HubSpot`, css, status, body });
|
|
28
|
+
}
|
|
29
|
+
export function redirect(location) {
|
|
30
|
+
return new Response(null, { status: 302, headers: { location } });
|
|
31
|
+
}
|
|
32
|
+
export async function formOf(request) {
|
|
33
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
34
|
+
}
|
|
35
|
+
/** The person a page needs, signed in to the account the page is of; a redirect to the sign-in, or a refusal, if not. */
|
|
36
|
+
export function personFor(request, hubId, root) {
|
|
37
|
+
const person = signedIn(request, root);
|
|
38
|
+
const url = new URL(request.url);
|
|
39
|
+
if (!person)
|
|
40
|
+
return redirect(`/login?loginRedirectUrl=${encodeURIComponent(url.pathname + url.search)}`);
|
|
41
|
+
const account = accountOf(Number(person.hubId), root);
|
|
42
|
+
if (!account || (hubId !== undefined && Number(person.hubId) !== hubId))
|
|
43
|
+
return page('HubSpot', CSS.portal, _jsx("main", { className: "portal-main", children: _jsx("h1", { children: "You don't have access to this account." }) }), 403);
|
|
44
|
+
if (account.closed_at)
|
|
45
|
+
return page('HubSpot', CSS.portal, _jsx("main", { className: "portal-main", children: _jsx("h1", { children: "This account has been closed." }) }), 403);
|
|
46
|
+
return { person, account };
|
|
47
|
+
}
|
|
48
|
+
/** The left column every account page carries. */
|
|
49
|
+
export const who = (account, person) => `${String(account.name)} · ${String(person.email)}`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function signIn(request: Request, root: string | undefined, at: string): Promise<Response>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// HUBSPOT'S SIGN-IN — app.hubspot.com/login (docs/contributing/architecture.md, "Who is on a screen"): a person gives
|
|
3
|
+
// the email and password the World gave them (accounts.ts's sign-up door), and the session cookie names them to every
|
|
4
|
+
// page after; a page asked for by nobody signed in sends its visitor here with `loginRedirectUrl`, and the sign-in returns
|
|
5
|
+
// them there. Built from @volter/world-ui's sign-in under HubSpot's skin.
|
|
6
|
+
//
|
|
7
|
+
// Where the documentation stops and the twin decides: the cookie is `hs_session`; a refused sign-in shows the form again
|
|
8
|
+
// with "The email or password you entered is incorrect." (200); HubSpot's own sign-in also offers Google, Microsoft and
|
|
9
|
+
// single sign-on, which the twin does not model.
|
|
10
|
+
import { SignIn } from '@volter/world-ui';
|
|
11
|
+
import { hex, keep, passwordHash, personOf, SESSION_COOKIE, stored } from "../accounts.js";
|
|
12
|
+
import { CSS, formOf, page } from "./page.js";
|
|
13
|
+
/** A return address on app.hubspot.com, never another site. */
|
|
14
|
+
const back = (raw) => (raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/');
|
|
15
|
+
function signInPage(to, email, error) {
|
|
16
|
+
return page('Log in', CSS.signIn, (_jsx(SignIn, { heading: "Log in to HubSpot", action: "/login", fields: { loginRedirectUrl: to }, account: { name: 'email', label: 'Email address', ...(email ? { value: email } : {}) }, password: { name: 'password', label: 'Password' }, submit: "Log in", ...(error ? { error } : {}) })));
|
|
17
|
+
}
|
|
18
|
+
export async function signIn(request, root, at) {
|
|
19
|
+
if (request.method === 'GET')
|
|
20
|
+
return signInPage(back(new URL(request.url).searchParams.get('loginRedirectUrl')));
|
|
21
|
+
const f = await formOf(request);
|
|
22
|
+
const email = (f.email ?? '').trim().toLowerCase();
|
|
23
|
+
const to = back(f.loginRedirectUrl);
|
|
24
|
+
const person = personOf(email, root);
|
|
25
|
+
if (!person || person.hash !== passwordHash(email, f.password ?? ''))
|
|
26
|
+
return signInPage(to, email, 'The email or password you entered is incorrect.');
|
|
27
|
+
const token = hex(`hubspot-session:${email}:${at}:${stored('_web_session', root).length + 1}`).slice(0, 40);
|
|
28
|
+
await keep('_web_session', `session:${token}`, { token, email, created_at: at }, root, at, email);
|
|
29
|
+
const headers = new Headers({ location: to });
|
|
30
|
+
headers.append('set-cookie', `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax`);
|
|
31
|
+
return new Response(null, { status: 302, headers });
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DerivedHandler } from '@volter/world-core';
|
|
2
|
+
/** The operations served by the pack's request handler, every CRM operation the twin serves. */
|
|
3
|
+
export declare const CRM_HANDLED: readonly ["delete-/crm/v3/objects/0-3/{dealId}_archive", "delete-/crm/v3/objects/companies/{companyId}_archive", "delete-/crm/v3/objects/contacts/{contactId}_archive", "delete-/crm/v3/objects/tickets/{ticketId}_archive", "get-/crm/v3/objects/0-3_getPage", "get-/crm/v3/objects/0-3/{dealId}_getById", "get-/crm/v3/objects/companies_getPage", "get-/crm/v3/objects/companies/{companyId}_getById", "get-/crm/v3/objects/contacts_getPage", "get-/crm/v3/objects/contacts/{contactId}_getById", "get-/crm/v3/objects/tickets_getPage", "get-/crm/v3/objects/tickets/{ticketId}_getById", "patch-/crm/v3/objects/0-3/{dealId}_update", "patch-/crm/v3/objects/companies/{companyId}_update", "patch-/crm/v3/objects/contacts/{contactId}_update", "patch-/crm/v3/objects/tickets/{ticketId}_update", "post-/crm/v3/objects/0-3_create", "post-/crm/v3/objects/0-3/batch/archive_archive", "post-/crm/v3/objects/0-3/batch/create_create", "post-/crm/v3/objects/0-3/batch/read_read", "post-/crm/v3/objects/0-3/batch/update_update", "post-/crm/v3/objects/0-3/batch/upsert_upsert", "post-/crm/v3/objects/0-3/merge_merge", "post-/crm/v3/objects/0-3/search_doSearch", "post-/crm/v3/objects/companies_create", "post-/crm/v3/objects/companies/batch/archive_archive", "post-/crm/v3/objects/companies/batch/create_create", "post-/crm/v3/objects/companies/batch/read_read", "post-/crm/v3/objects/companies/batch/update_update", "post-/crm/v3/objects/companies/batch/upsert_upsert", "post-/crm/v3/objects/companies/merge_merge", "post-/crm/v3/objects/companies/search_doSearch", "post-/crm/v3/objects/contacts_create", "post-/crm/v3/objects/contacts/batch/archive_archive", "post-/crm/v3/objects/contacts/batch/create_create", "post-/crm/v3/objects/contacts/batch/read_read", "post-/crm/v3/objects/contacts/batch/update_update", "post-/crm/v3/objects/contacts/batch/upsert_upsert", "post-/crm/v3/objects/contacts/merge_merge", "post-/crm/v3/objects/contacts/search_doSearch", "post-/crm/v3/objects/tickets_create", "post-/crm/v3/objects/tickets/batch/archive_archive", "post-/crm/v3/objects/tickets/batch/create_create", "post-/crm/v3/objects/tickets/batch/read_read", "post-/crm/v3/objects/tickets/batch/update_update", "post-/crm/v3/objects/tickets/batch/upsert_upsert", "post-/crm/v3/objects/tickets/merge_merge", "post-/crm/v3/objects/tickets/search_doSearch", "get-/crm/v3/owners/{ownerId}_getById", "get-/crm/v3/owners_/crm/v3/owners", "delete-/crm/v3/pipelines/{objectType}/{pipelineId}_archive", "delete-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_archive", "get-/crm/v3/pipelines/{objectType}_getAll", "get-/crm/v3/pipelines/{objectType}/{pipelineId}_getById", "get-/crm/v3/pipelines/{objectType}/{pipelineId}/stages_getAll", "get-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_getById", "patch-/crm/v3/pipelines/{objectType}/{pipelineId}_update", "patch-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_update", "post-/crm/v3/pipelines/{objectType}_create", "post-/crm/v3/pipelines/{objectType}/{pipelineId}/stages_create", "put-/crm/v3/pipelines/{objectType}/{pipelineId}_replace", "put-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_replace", "delete-/crm/v3/properties/{objectType}/{propertyName}_archive", "delete-/crm/v3/properties/{objectType}/groups/{groupName}_archive", "get-/crm/v3/properties/{objectType}_getAll", "get-/crm/v3/properties/{objectType}/{propertyName}_getByName", "get-/crm/v3/properties/{objectType}/groups_getAll", "get-/crm/v3/properties/{objectType}/groups/{groupName}_getByName", "patch-/crm/v3/properties/{objectType}/{propertyName}_update", "patch-/crm/v3/properties/{objectType}/groups/{groupName}_update", "post-/crm/v3/properties/{objectType}_create", "post-/crm/v3/properties/{objectType}/batch/create_create", "post-/crm/v3/properties/{objectType}/groups_create", "post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/archive_archive", "post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/create_create", "post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/read_getPage", "delete-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}_archive", "get-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}_getPage", "put-/crm/v4/objects/{fromObjectType}/{fromObjectId}/associations/default/{toObjectType}/{toObjectId}_createDefault", "put-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}_create"];
|
|
4
|
+
/** The operations served by the OAuth install's token endpoints. */
|
|
5
|
+
export declare const OAUTH_HANDLED: readonly ["get-/oauth/v1/access-tokens/{token}_get", "post-/oauth/v1/token_create"];
|
|
6
|
+
/** A handler per operation above: the CRM ones answer through `answer` (the pack's request handler as a fetch), the
|
|
7
|
+
* OAuth ones through `oauth`. */
|
|
8
|
+
export declare function hubspotHandlers(answer: (request: Request) => Promise<Response>, oauth: (request: Request) => Promise<Response>): Record<string, DerivedHandler>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** The operations served by the pack's request handler, every CRM operation the twin serves. */
|
|
2
|
+
export const CRM_HANDLED = [
|
|
3
|
+
// /crm/v3/objects
|
|
4
|
+
'delete-/crm/v3/objects/0-3/{dealId}_archive',
|
|
5
|
+
'delete-/crm/v3/objects/companies/{companyId}_archive',
|
|
6
|
+
'delete-/crm/v3/objects/contacts/{contactId}_archive',
|
|
7
|
+
'delete-/crm/v3/objects/tickets/{ticketId}_archive',
|
|
8
|
+
'get-/crm/v3/objects/0-3_getPage',
|
|
9
|
+
'get-/crm/v3/objects/0-3/{dealId}_getById',
|
|
10
|
+
'get-/crm/v3/objects/companies_getPage',
|
|
11
|
+
'get-/crm/v3/objects/companies/{companyId}_getById',
|
|
12
|
+
'get-/crm/v3/objects/contacts_getPage',
|
|
13
|
+
'get-/crm/v3/objects/contacts/{contactId}_getById',
|
|
14
|
+
'get-/crm/v3/objects/tickets_getPage',
|
|
15
|
+
'get-/crm/v3/objects/tickets/{ticketId}_getById',
|
|
16
|
+
'patch-/crm/v3/objects/0-3/{dealId}_update',
|
|
17
|
+
'patch-/crm/v3/objects/companies/{companyId}_update',
|
|
18
|
+
'patch-/crm/v3/objects/contacts/{contactId}_update',
|
|
19
|
+
'patch-/crm/v3/objects/tickets/{ticketId}_update',
|
|
20
|
+
'post-/crm/v3/objects/0-3_create',
|
|
21
|
+
'post-/crm/v3/objects/0-3/batch/archive_archive',
|
|
22
|
+
'post-/crm/v3/objects/0-3/batch/create_create',
|
|
23
|
+
'post-/crm/v3/objects/0-3/batch/read_read',
|
|
24
|
+
'post-/crm/v3/objects/0-3/batch/update_update',
|
|
25
|
+
'post-/crm/v3/objects/0-3/batch/upsert_upsert',
|
|
26
|
+
'post-/crm/v3/objects/0-3/merge_merge',
|
|
27
|
+
'post-/crm/v3/objects/0-3/search_doSearch',
|
|
28
|
+
'post-/crm/v3/objects/companies_create',
|
|
29
|
+
'post-/crm/v3/objects/companies/batch/archive_archive',
|
|
30
|
+
'post-/crm/v3/objects/companies/batch/create_create',
|
|
31
|
+
'post-/crm/v3/objects/companies/batch/read_read',
|
|
32
|
+
'post-/crm/v3/objects/companies/batch/update_update',
|
|
33
|
+
'post-/crm/v3/objects/companies/batch/upsert_upsert',
|
|
34
|
+
'post-/crm/v3/objects/companies/merge_merge',
|
|
35
|
+
'post-/crm/v3/objects/companies/search_doSearch',
|
|
36
|
+
'post-/crm/v3/objects/contacts_create',
|
|
37
|
+
'post-/crm/v3/objects/contacts/batch/archive_archive',
|
|
38
|
+
'post-/crm/v3/objects/contacts/batch/create_create',
|
|
39
|
+
'post-/crm/v3/objects/contacts/batch/read_read',
|
|
40
|
+
'post-/crm/v3/objects/contacts/batch/update_update',
|
|
41
|
+
'post-/crm/v3/objects/contacts/batch/upsert_upsert',
|
|
42
|
+
'post-/crm/v3/objects/contacts/merge_merge',
|
|
43
|
+
'post-/crm/v3/objects/contacts/search_doSearch',
|
|
44
|
+
'post-/crm/v3/objects/tickets_create',
|
|
45
|
+
'post-/crm/v3/objects/tickets/batch/archive_archive',
|
|
46
|
+
'post-/crm/v3/objects/tickets/batch/create_create',
|
|
47
|
+
'post-/crm/v3/objects/tickets/batch/read_read',
|
|
48
|
+
'post-/crm/v3/objects/tickets/batch/update_update',
|
|
49
|
+
'post-/crm/v3/objects/tickets/batch/upsert_upsert',
|
|
50
|
+
'post-/crm/v3/objects/tickets/merge_merge',
|
|
51
|
+
'post-/crm/v3/objects/tickets/search_doSearch',
|
|
52
|
+
// /crm/v3/owners
|
|
53
|
+
'get-/crm/v3/owners/{ownerId}_getById',
|
|
54
|
+
'get-/crm/v3/owners_/crm/v3/owners',
|
|
55
|
+
// /crm/v3/pipelines
|
|
56
|
+
'delete-/crm/v3/pipelines/{objectType}/{pipelineId}_archive',
|
|
57
|
+
'delete-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_archive',
|
|
58
|
+
'get-/crm/v3/pipelines/{objectType}_getAll',
|
|
59
|
+
'get-/crm/v3/pipelines/{objectType}/{pipelineId}_getById',
|
|
60
|
+
'get-/crm/v3/pipelines/{objectType}/{pipelineId}/stages_getAll',
|
|
61
|
+
'get-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_getById',
|
|
62
|
+
'patch-/crm/v3/pipelines/{objectType}/{pipelineId}_update',
|
|
63
|
+
'patch-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_update',
|
|
64
|
+
'post-/crm/v3/pipelines/{objectType}_create',
|
|
65
|
+
'post-/crm/v3/pipelines/{objectType}/{pipelineId}/stages_create',
|
|
66
|
+
'put-/crm/v3/pipelines/{objectType}/{pipelineId}_replace',
|
|
67
|
+
'put-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_replace',
|
|
68
|
+
// /crm/v3/properties
|
|
69
|
+
'delete-/crm/v3/properties/{objectType}/{propertyName}_archive',
|
|
70
|
+
'delete-/crm/v3/properties/{objectType}/groups/{groupName}_archive',
|
|
71
|
+
'get-/crm/v3/properties/{objectType}_getAll',
|
|
72
|
+
'get-/crm/v3/properties/{objectType}/{propertyName}_getByName',
|
|
73
|
+
'get-/crm/v3/properties/{objectType}/groups_getAll',
|
|
74
|
+
'get-/crm/v3/properties/{objectType}/groups/{groupName}_getByName',
|
|
75
|
+
'patch-/crm/v3/properties/{objectType}/{propertyName}_update',
|
|
76
|
+
'patch-/crm/v3/properties/{objectType}/groups/{groupName}_update',
|
|
77
|
+
'post-/crm/v3/properties/{objectType}_create',
|
|
78
|
+
'post-/crm/v3/properties/{objectType}/batch/create_create',
|
|
79
|
+
'post-/crm/v3/properties/{objectType}/groups_create',
|
|
80
|
+
// /crm/v4/associations
|
|
81
|
+
'post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/archive_archive',
|
|
82
|
+
'post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/create_create',
|
|
83
|
+
'post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/read_getPage',
|
|
84
|
+
// /crm/v4/objects
|
|
85
|
+
'delete-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}_archive',
|
|
86
|
+
'get-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}_getPage',
|
|
87
|
+
'put-/crm/v4/objects/{fromObjectType}/{fromObjectId}/associations/default/{toObjectType}/{toObjectId}_createDefault',
|
|
88
|
+
'put-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}_create',
|
|
89
|
+
];
|
|
90
|
+
/** The operations served by the OAuth install's token endpoints. */
|
|
91
|
+
export const OAUTH_HANDLED = [
|
|
92
|
+
'get-/oauth/v1/access-tokens/{token}_get',
|
|
93
|
+
'post-/oauth/v1/token_create',
|
|
94
|
+
];
|
|
95
|
+
/** A handler per operation above: the CRM ones answer through `answer` (the pack's request handler as a fetch), the
|
|
96
|
+
* OAuth ones through `oauth`. */
|
|
97
|
+
export function hubspotHandlers(answer, oauth) {
|
|
98
|
+
const crm = ({ request }) => answer(request);
|
|
99
|
+
const token = ({ request }) => oauth(request);
|
|
100
|
+
return { ...Object.fromEntries(CRM_HANDLED.map((id) => [id, crm])), ...Object.fromEntries(OAUTH_HANDLED.map((id) => [id, token])) };
|
|
101
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type WriteEvent = {
|
|
2
|
+
objectType: string;
|
|
3
|
+
objectId: string;
|
|
4
|
+
kind: 'creation' | 'propertyChange';
|
|
5
|
+
propertyName?: string;
|
|
6
|
+
propertyValue?: string | null;
|
|
7
|
+
};
|
|
8
|
+
/** Announce a stored write to every app installed in the request's account that subscribes to it. */
|
|
9
|
+
export declare function announceWrite(e: WriteEvent, root: string | undefined, at: string): Promise<void>;
|
|
10
|
+
/** GET /_twin/deliveries?to=<url>: what an app's server received from HubSpot, oldest first — each request's headers,
|
|
11
|
+
* its body as sent, and the events in it. */
|
|
12
|
+
export declare function deliveriesDoor(request: Request, root?: string): Response;
|