@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// @volter/twin-hubspot — the HubSpot CRM twin (one vendor, one package), built on the shared
|
|
2
|
+
// @volter/world-core kernel. REST transport over HubSpot's real v3/v4 CRM paths, the vendor's own
|
|
3
|
+
// `{ results, paging: { next: { after } } }` collection envelope and
|
|
4
|
+
// `{ status:'error', message, correlationId, category }` error envelope, stateful CRM records
|
|
5
|
+
// (contacts / companies / deals / tickets) with properties, associations, pipelines and owners.
|
|
6
|
+
// The CRM API accepts any bearer; the OAuth install (consent and token endpoints) mints the tokens an app is given.
|
|
7
|
+
// Served as a derived pack (Protocol 3, src/manifest.ts). Conformance tooling lives in
|
|
8
|
+
// @volter/world-tooling (a dev dependency) — not shipped in the runtime API.
|
|
9
|
+
export {
|
|
10
|
+
handleHubspotTwinRequest,
|
|
11
|
+
correlationIdFor,
|
|
12
|
+
resolveObjectType,
|
|
13
|
+
HUBSPOT_RATE_LIMIT_HEADERS,
|
|
14
|
+
LIST_LIMIT_MAX,
|
|
15
|
+
OBJECT_TYPES,
|
|
16
|
+
RESOURCE_TYPES,
|
|
17
|
+
SEARCH_FILTER_OPERATORS,
|
|
18
|
+
SEARCH_LIMIT_MAX,
|
|
19
|
+
SEARCH_MAX_FILTERS_PER_GROUP,
|
|
20
|
+
SEARCH_MAX_FILTERS_TOTAL,
|
|
21
|
+
SEARCH_MAX_FILTER_GROUPS,
|
|
22
|
+
SEARCH_RESULT_CAP,
|
|
23
|
+
} from './hubspot-twin.ts';
|
|
24
|
+
export type { HubspotRequest, HubspotResponse, ObjectType } from './hubspot-twin.ts';
|
|
25
|
+
export { createHubspotTwinFetch, createHubspotTwinServer, type HubspotTwinFetchOptions } from './hubspot-server.ts';
|
|
26
|
+
export {
|
|
27
|
+
hubspotRequestForAction,
|
|
28
|
+
liveHubspotExecute,
|
|
29
|
+
mapCrmObject,
|
|
30
|
+
mapOwner,
|
|
31
|
+
pollTimestamp,
|
|
32
|
+
pullHubspotAll,
|
|
33
|
+
pullHubspotObjects,
|
|
34
|
+
pullHubspotOwners,
|
|
35
|
+
pushPendingHubspotActions,
|
|
36
|
+
syncHubspotFromReal,
|
|
37
|
+
} from './hubspot-connector.ts';
|
|
38
|
+
export type { HubspotErrorBody, HubspotExecute, LiveHubspotOptions } from './hubspot-connector.ts';
|
|
39
|
+
// The client-side rate budget — the fail-closed backstop `liveHubspotExecute` routes every live
|
|
40
|
+
// request through. The MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives
|
|
41
|
+
// here is HubSpot's DECLARATION (window/ceiling/per-endpoint weights) plus the vendor-bound
|
|
42
|
+
// bindings. Exported so an operator can inspect spend (`snapshot`) and so a caller can catch
|
|
43
|
+
// `HubspotBudgetError` by type; there is deliberately no export that disables the guard.
|
|
44
|
+
export {
|
|
45
|
+
HUBSPOT_BUDGET_CEILING,
|
|
46
|
+
HUBSPOT_BUDGET_MAX_RETRY_AFTER_S,
|
|
47
|
+
HUBSPOT_BUDGET_WINDOW_MS,
|
|
48
|
+
HUBSPOT_CALL_WEIGHTS,
|
|
49
|
+
HUBSPOT_RATE_BUDGET,
|
|
50
|
+
HubspotBudget,
|
|
51
|
+
HubspotBudgetError,
|
|
52
|
+
hubspotBudgetPath,
|
|
53
|
+
hubspotCallWeight,
|
|
54
|
+
} from './hubspot-budget.ts';
|
|
55
|
+
export type { HubspotBudgetErrorKind, HubspotBudgetOptions, HubspotBudgetReservation, HubspotBudgetSnapshot } from './hubspot-budget.ts';
|
|
56
|
+
export {
|
|
57
|
+
MIRROR_SECTIONS,
|
|
58
|
+
SECTION_COLUMNS,
|
|
59
|
+
TITLE_PROPERTY,
|
|
60
|
+
buildHubspotMirrorClient,
|
|
61
|
+
createHubspotMirrorServer,
|
|
62
|
+
formatAmount,
|
|
63
|
+
groupByStage,
|
|
64
|
+
hubspotMirrorHtml,
|
|
65
|
+
recordTitle,
|
|
66
|
+
stageTone,
|
|
67
|
+
} from './hubspot-mirror-ui.ts';
|
|
68
|
+
export type { HubspotRow, MirrorSectionKey, PillTone } from './hubspot-mirror-ui.ts';
|
|
69
|
+
|
|
70
|
+
// Registry descriptor: the pack self-describes so tooling can discover it.
|
|
71
|
+
import { registerPack, type TwinPack } from '@volter/world-core';
|
|
72
|
+
import { performHubspotAction, syncHubspotFromRemote } from './hubspot-connector.ts';
|
|
73
|
+
import { HUBSPOT_RATE_BUDGET as RATE_BUDGET } from './hubspot-budget.ts';
|
|
74
|
+
export const pack: TwinPack = {
|
|
75
|
+
// PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a plugin — its wire, its tree, and its half of
|
|
76
|
+
// the real state system. Moved 2026-09-08.
|
|
77
|
+
protocol: '2',
|
|
78
|
+
// HubSpot publishes webhooks for CRM object changes, which is what actually moves; the refresh
|
|
79
|
+
// walks every object type and is the backstop.
|
|
80
|
+
refresh: { every: '15m', webhook: true, onDemand: { atMost: '30s' } },
|
|
81
|
+
stateSystem: { perform: performHubspotAction, refresh: syncHubspotFromRemote },
|
|
82
|
+
// The round trip is a CONTACT create — HubSpot's own write, and the portal mints a fresh record
|
|
83
|
+
// id for every one, so a second send on a branch is a second contact rather than a collision.
|
|
84
|
+
roundTrip: { method: 'POST', path: '/crm/v3/objects/contacts', body: { properties: { email: 'round@trip.test', firstname: 'Round', lastname: 'Trip' } }, headers: { authorization: 'Bearer twin' } },
|
|
85
|
+
parityOrigin: 'http://twin',
|
|
86
|
+
|
|
87
|
+
vendor: 'hubspot',
|
|
88
|
+
// The SAME object hubspot-budget.ts declares at module load — one source of truth, so
|
|
89
|
+
// registering the pack and importing the connector can never arm two different ceilings.
|
|
90
|
+
rateBudget: RATE_BUDGET,
|
|
91
|
+
transport: 'rest',
|
|
92
|
+
archetype: 'crud',
|
|
93
|
+
bin: 'world-hubspot',
|
|
94
|
+
// R2 adopted-as-debt, legibly: declared types no replay can create, each with its reason.
|
|
95
|
+
resourcesUnreachable: {
|
|
96
|
+
'owner': "a portal user provisioned in HubSpot's admin, never through the CRM API",
|
|
97
|
+
},
|
|
98
|
+
resources: ['crm_object', 'property', 'property_group', 'pipeline', 'association', 'owner'],
|
|
99
|
+
specSource:
|
|
100
|
+
"nine documents of HubSpot's public spec catalog (api.hubspot.com/public/api/spec/v1/specs): Contacts, Companies, "
|
|
101
|
+
+ 'Deals, Tickets, Crm Owners, Properties and Pipelines v3, Associations v4 and OAuth v1, vendored under spec/openapi/ '
|
|
102
|
+
+ '(provenance in spec/SOURCE.md) and read as their union; the calls dubinc/dub makes (apps/web/lib/integrations/hubspot/).',
|
|
103
|
+
description: 'HubSpot CRM twin — v3/v4 objects, properties, associations, pipelines and owners, with the vendor error envelope, paging.next.after and a CRM mirror.',
|
|
104
|
+
// A browser-side HubSpot call goes to api.hubapi.com/crm/… — the dev proxy forwards '/crm/'
|
|
105
|
+
// to the twin and strips the absolute host so calls come back same-origin.
|
|
106
|
+
browserRouting: { apiPathPrefix: '/crm/', loaderHost: 'https://api.hubapi.com' },
|
|
107
|
+
|
|
108
|
+
// ADOPTION — how an app repo betrays that it talks to HubSpot.
|
|
109
|
+
// • `@hubspot/api-client` is the ONE official npm client of the surface modeled here.
|
|
110
|
+
// • The `@hubspot/` scope is NOT claimed wholesale: it also carries the CMS/CLI toolchain
|
|
111
|
+
// (`@hubspot/cli`, `@hubspot/local-dev-lib`, the `@hubspot/cms-components` family), which
|
|
112
|
+
// are build tooling for a product area this twin explicitly does not model. Claiming the
|
|
113
|
+
// scope would report a CMS-theme repo as covered by a CRM twin — the exact lie `covers`
|
|
114
|
+
// exists to prevent (the `googleapis` precedent in covers.ts).
|
|
115
|
+
// • `HUBSPOT` is the credential stem: HUBSPOT_ACCESS_TOKEN / HUBSPOT_API_KEY /
|
|
116
|
+
// HUBSPOT_CLIENT_ID / HUBSPOT_CLIENT_SECRET all normalize to it under covers.ts's
|
|
117
|
+
// ENV_SUFFIX rules (framework prefix stripped, suffix matched, remainder lowercased with
|
|
118
|
+
// punctuation removed). `HUBSPOT_PRIVATE_APP_TOKEN` — the name HubSpot's own private-app
|
|
119
|
+
// docs use — stems to `hubspotprivateapp` instead, because `_TOKEN` is the matched suffix,
|
|
120
|
+
// so it is claimed explicitly rather than assumed.
|
|
121
|
+
adoption: {
|
|
122
|
+
// HubSpot's official Python client, the counterpart of `@hubspot/api-client`.
|
|
123
|
+
pypi: ['hubspot-api-client'],
|
|
124
|
+
sdks: ['@hubspot/api-client'],
|
|
125
|
+
envStems: ['HUBSPOT', 'HUBSPOTPRIVATEAPP'],
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
// INTERCEPTION — api.hubapi.com is the SDK's only API server: every generated
|
|
129
|
+
// `servers.ts` in @hubspot/api-client 14.0.1 declares exactly
|
|
130
|
+
// `new ServerConfiguration("https://api.hubapi.com", {})`, and a sweep of the whole published
|
|
131
|
+
// tarball finds no other API host. `app.hubspot.com` appears once, in
|
|
132
|
+
// `OauthDiscovery.getAuthorizationUrl`: the URL a person is sent to for consent, which the twin serves
|
|
133
|
+
// (hubspot-oauth.tsx).
|
|
134
|
+
// app.hubspot.com carries every page the twin builds (sign-in, consent, apps, records, forms, settings) and
|
|
135
|
+
// api.hsforms.com a form's submission (src/screens/).
|
|
136
|
+
hosts: [{ host: 'api.hubapi.com' }, { host: 'app.hubspot.com' }, { host: 'api.hsforms.com' }],
|
|
137
|
+
|
|
138
|
+
// WORLD WIRING — `endpointEnvNone`, and this is a ruling, not an omission. The SDK IS
|
|
139
|
+
// base-URL-configurable, but through a CONSTRUCTOR OPTION (`IConfiguration.basePath`, read by
|
|
140
|
+
// `ApiClientConfigurator.getBaseServer`), never an environment variable: nothing in
|
|
141
|
+
// @hubspot/api-client 14.0.1 reads `process.env`, and HubSpot documents no `HUBSPOT_BASE_URL`.
|
|
142
|
+
// Inventing one would make `covers` report a world covered while the app, reading no such var,
|
|
143
|
+
// still talked to the real portal. Interception is the `hosts` entry above.
|
|
144
|
+
endpointEnvNone:
|
|
145
|
+
'the @hubspot/api-client SDK takes its base URL from the `basePath` CONSTRUCTOR option '
|
|
146
|
+
+ '(IConfiguration.basePath → ApiClientConfigurator.getBaseServer), not from any environment '
|
|
147
|
+
+ 'variable — it reads no process.env at all, and HubSpot documents no base-URL env var. '
|
|
148
|
+
+ 'Interception is the api.hubapi.com host entry; inventing a HUBSPOT_BASE_URL the app does '
|
|
149
|
+
+ 'not read would report a world covered while its traffic still left the machine.',
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
registerPack(pack);
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// HubSpot's manifest: the vendor facts its published documents do not carry (docs/contributing/architecture.md,
|
|
2
|
+
// "Protocol 3"). The surface is generated (./generated/surface.gen.json, from ../spec by scripts/derive-pack.ts): the
|
|
3
|
+
// union of nine documents from HubSpot's public spec catalog (the eight CRM documents the pack serves and OAuth v1,
|
|
4
|
+
// ../spec/SOURCE.md), their one OAuth scheme's scope lists made one (../spec/patches.json). The documents name every
|
|
5
|
+
// operation, so an operation's id is HubSpot's own (`post-/crm/v3/properties/{objectType}/batch/create_create`).
|
|
6
|
+
//
|
|
7
|
+
// THE HANDLERS. Every operation the twin serves is a semantics handler (semantics/crm.ts) over the pack's request
|
|
8
|
+
// handler (hubspot-twin.ts), whose store is the vendor's behaviour, or over the OAuth install's token endpoints
|
|
9
|
+
// (hubspot-oauth.tsx). The derived core owns no operation, so no resource is declared here and no state candidate is
|
|
10
|
+
// ruled: the spec's resources (a record, a property, a pipeline and its stages, an owner) are stored by the request
|
|
11
|
+
// handler in its own shape. An operation no handler serves answers the gap (hubspot-server.ts).
|
|
12
|
+
//
|
|
13
|
+
// WHY NO `auth`. HubSpot reads `Authorization: Bearer <access token or static app token>`. A token the twin issued (an
|
|
14
|
+
// install's access token, a static app's token) acts in its account (portal.ts) and is refused once expired, its app
|
|
15
|
+
// uninstalled or its account closed (hubspot-oauth.tsx, tokenPortal); any other bearer acts in the World's own account,
|
|
16
|
+
// as it did before accounts were modelled, and the kernel's `auth` names keys the twin refuses, never the ones it
|
|
17
|
+
// accepts. Where the twin stops: no operation is refused for a scope its token lacks.
|
|
18
|
+
//
|
|
19
|
+
// THE ERROR BODY. `{status: "error", message, correlationId, category}`, HubSpot's documented error envelope
|
|
20
|
+
// (https://developers.hubspot.com/docs/api-reference/error-handling), which the request handler answers with a
|
|
21
|
+
// correlationId derived from the request (so a replay is byte-identical). The template below is the core's, which
|
|
22
|
+
// serves nothing.
|
|
23
|
+
//
|
|
24
|
+
// THE STATE. The request handler's kernel subjects: `crm_object` (a record, keyed `<objectType>_<id>`), `property`,
|
|
25
|
+
// `property_group`, `pipeline` (its stages inside), `owner` (pulled or the seeded portal owner) and `association`;
|
|
26
|
+
// the OAuth install's `oauth_code` and `oauth_token`.
|
|
27
|
+
import type { DerivedManifest } from '@volter/world-core';
|
|
28
|
+
|
|
29
|
+
export const manifest: DerivedManifest = {
|
|
30
|
+
vendor: 'hubspot',
|
|
31
|
+
service: 'hubspot',
|
|
32
|
+
body: {},
|
|
33
|
+
// the core mints no id (it owns no operation); a record's id is the request handler's
|
|
34
|
+
ids: { template: '{prefix}_{n}' },
|
|
35
|
+
time: 'iso',
|
|
36
|
+
error: { status: 'error', message: '{message}', category: '{code}' },
|
|
37
|
+
readOnly: { status: 405, message: 'this twin was started read-only; omit readOnly to accept writes', code: 'TWIN_READ_ONLY' },
|
|
38
|
+
malformedBody: { status: 400, message: 'Invalid input JSON', code: 'VALIDATION_ERROR' },
|
|
39
|
+
notFound: { status: 404, message: 'resource not found', code: 'OBJECT_NOT_FOUND' },
|
|
40
|
+
// `{results, paging: {next: {after}}}`, paged by `after` and `limit` (10 by default, 100 at most: the documents'
|
|
41
|
+
// getPage operations). Required by the type; the core lists nothing.
|
|
42
|
+
list: { style: 'envelope', envelope: { results: '{data}' }, limit: { param: 'limit', default: 10, max: 100 }, after: 'after' },
|
|
43
|
+
deleted: {},
|
|
44
|
+
// HubSpot's pages the life walks (src/screens/, src/hubspot-oauth.tsx), each built from @volter/world-ui's pieces.
|
|
45
|
+
screens: [
|
|
46
|
+
{
|
|
47
|
+
id: 'sign-in', kind: 'flow', host: 'app.hubspot.com', path: '/login',
|
|
48
|
+
demand: 'every person on a HubSpot page is who signed in there', status: 'done', controls: ['Email address', 'Password', 'Log in'],
|
|
49
|
+
source: 'https://knowledge.hubspot.com/account-management/log-in-to-hubspot',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'oauth-consent', kind: 'flow', host: 'app.hubspot.com', path: '/oauth/authorize',
|
|
53
|
+
demand: "Dub's HubSpot integration sends a workspace owner here to connect a portal (apps/web/lib/integrations/hubspot/oauth.ts, authUrl)",
|
|
54
|
+
status: 'done', controls: ['Select an account', 'Connect app', 'Cancel', 'Type I accept the risk', 'Connect'],
|
|
55
|
+
source: 'https://developers.hubspot.com/docs/apps/developer-platform/build-apps/manage-apps-in-hubspot',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'app-auth', kind: 'workspace', host: 'app.hubspot.com', path: '/developer-projects/:hubId/project/:name/component/:uid/auth',
|
|
59
|
+
demand: "an app's client ID and secret are read here", status: 'done',
|
|
60
|
+
source: 'https://developers.hubspot.com/docs/apps/developer-platform/build-apps/manage-apps-in-hubspot',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'app-distribution', kind: 'workspace', host: 'app.hubspot.com', path: '/developer-projects/:hubId/project/:name/component/:uid/distribution',
|
|
64
|
+
demand: 'an OAuth marketplace app is published (Begin publishing, the AUP) before any install; a static app is installed and its token shown here',
|
|
65
|
+
status: 'done', controls: ['Begin publishing', 'Sign', 'Install now', 'Show'],
|
|
66
|
+
source: 'https://developers.hubspot.com/docs/apps/developer-platform/build-apps/manage-apps-in-hubspot',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'records', kind: 'workspace', host: 'app.hubspot.com', path: '/contacts/:hubId/record/:objectTypeId/:id',
|
|
70
|
+
demand: "reps create deals on a contact's record and move a deal's stage; account managers add companies", status: 'done',
|
|
71
|
+
controls: ['Create deal', 'Deal stage', 'Save', 'Create company'], source: 'https://knowledge.hubspot.com/records/create-records-on-the-updated-index-page',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'pipelines', kind: 'workspace', host: 'app.hubspot.com', path: '/settings/:hubId/objects/deals/pipelines',
|
|
75
|
+
demand: "a custom pipeline's closed-won stage ID is read here (Dub's guide: \"enter the stage ID corresponding to your custom Closed Won stage\")",
|
|
76
|
+
status: 'done', source: 'https://knowledge.hubspot.com/object-settings/set-up-and-customize-pipelines',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'forms', kind: 'workspace', host: 'app.hubspot.com', path: '/forms/:hubId',
|
|
80
|
+
demand: "Dub's guide: \"In the HubSpot form builder, add a hidden field and map it to the Dub Id contact property\"", status: 'done',
|
|
81
|
+
controls: ['Create form', 'Add hidden field'], source: 'https://dub.co/docs/integrations/hubspot',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'connected-apps', kind: 'workspace', host: 'app.hubspot.com', path: '/connected-apps/:hubId/installed',
|
|
85
|
+
demand: 'a client uninstalls the app it connected', status: 'done', controls: ['Uninstall', 'Type uninstall'],
|
|
86
|
+
source: 'https://knowledge.hubspot.com/marketplace/install-apps-in-the-hubspot-marketplace',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'account-and-billing', kind: 'workspace', host: 'app.hubspot.com', path: '/account-and-billing/:hubId/subscriptions',
|
|
90
|
+
demand: "a company leaving HubSpot cancels its subscription's auto-renewal, then deletes the account once the subscription has ended",
|
|
91
|
+
status: 'done', controls: ['Cancel auto-renewal', 'Next', 'Delete account', 'Enter your Hub ID to confirm', 'Yes, delete'],
|
|
92
|
+
source: 'https://knowledge.hubspot.com/account/how-do-i-cancel-my-hubspot-account',
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
resources: {},
|
|
96
|
+
};
|
package/src/portal.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// THE PORTAL A REQUEST ACTS IN — HubSpot keeps each account's CRM apart: a record, a property, a pipeline and an owner
|
|
2
|
+
// belong to one account ("portal", its Hub ID), a token acts in the account its app was installed in (the token
|
|
3
|
+
// exchange answers that account's `hub_id`), and a webhook event names the account where it occurred in its `portalId`
|
|
4
|
+
// (https://developers.hubspot.com/docs/apps/legacy-apps/public-apps/create-generic-webhook-subscriptions).
|
|
5
|
+
//
|
|
6
|
+
// The request handler (hubspot-twin.ts) reads and writes through `inPortal`'s scope: a subject of an account other than
|
|
7
|
+
// the World's own is stored under the account's id and read only in its scope. A request whose credential the twin
|
|
8
|
+
// never issued acts in the World's own account, `TWIN_HUB_ID`, as every request did before accounts were modelled, so a
|
|
9
|
+
// World pointing an app at the twin with a token of its own keeps working.
|
|
10
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
11
|
+
|
|
12
|
+
/** The World's own account: every request whose credential names no account the twin made acts here. */
|
|
13
|
+
export const TWIN_HUB_ID = 2953265;
|
|
14
|
+
|
|
15
|
+
/** Who is acting, in which account, for a write's webhook events: `changeSource` and `sourceId` as HubSpot names
|
|
16
|
+
* them in an event ("The source of the change", "The user or source of the triggered webhook event"). */
|
|
17
|
+
export type PortalScope = { hub: number; source: string; sourceId?: string };
|
|
18
|
+
|
|
19
|
+
const scope = new AsyncLocalStorage<PortalScope>();
|
|
20
|
+
|
|
21
|
+
/** Run `fn` acting in `hub`. */
|
|
22
|
+
export function inPortal<T>(s: PortalScope, fn: () => T): T {
|
|
23
|
+
return scope.run(s, fn);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The scope the current request acts in; the World's own account, through the API, when none was opened. */
|
|
27
|
+
export function currentPortal(): PortalScope {
|
|
28
|
+
return scope.getStore() ?? { hub: TWIN_HUB_ID, source: 'API' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A subject's kernel id in an account: the World's own account keeps the ids it always had. */
|
|
32
|
+
export function scopedId(hub: number, id: string): string {
|
|
33
|
+
return hub === TWIN_HUB_ID ? id : `p${hub}~${id}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A stored subject's id as its account sees it, and the account it belongs to. */
|
|
37
|
+
export function unscoped(id: string): { hub: number; id: string } {
|
|
38
|
+
const m = /^p(\d+)~(.*)$/.exec(id);
|
|
39
|
+
return m ? { hub: Number(m[1]), id: m[2]! } : { hub: TWIN_HUB_ID, id };
|
|
40
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// AN ACCOUNT'S OWN SETTINGS — its home, the apps connected to it (where a super admin uninstalls one), and its account
|
|
2
|
+
// and billing pages (where a super admin cancels a subscription's auto-renewal, and deletes the account).
|
|
3
|
+
//
|
|
4
|
+
// - app.hubspot.com/ : the signed-in person's account, with the pages the twin serves for it.
|
|
5
|
+
// - app.hubspot.com/connected-apps/:hubId/installed : the apps installed in the account. "Click Actions on the app you
|
|
6
|
+
// want to disconnect, then click Uninstall. In the dialog box, type "uninstall" in the text field and click Uninstall"
|
|
7
|
+
// (https://knowledge.hubspot.com/marketplace/install-apps-in-the-hubspot-marketplace, Uninstall an app). An
|
|
8
|
+
// uninstalled app's tokens stop working and it is sent no more webhook events (oauth: tokenPortal; webhooks.ts).
|
|
9
|
+
// - app.hubspot.com/account-and-billing/:hubId/subscriptions : the account's subscription and its auto-renewal terms;
|
|
10
|
+
// **Cancel auto-renewal**, choosing the products and a cancellation reason, then reviewing and confirming. "Mid-contract
|
|
11
|
+
// cancellations are not permitted": the subscription ends on its renewal date, when "your account will be downgraded to
|
|
12
|
+
// HubSpot's free tools" (accounts.ts, subscriptionAt).
|
|
13
|
+
// - app.hubspot.com/account-and-billing/:hubId/delete-account : the Delete Account tab. "Click Delete account. You'll be
|
|
14
|
+
// prompted to enter your Hub ID to confirm the deletion. Click Yes, delete"; "You cannot delete your account if any
|
|
15
|
+
// HubSpot subscriptions still have active commitments"
|
|
16
|
+
// (https://knowledge.hubspot.com/account/how-do-i-cancel-my-hubspot-account). A deleted account's pages, tokens and
|
|
17
|
+
// forms stop answering.
|
|
18
|
+
//
|
|
19
|
+
// Where the documentation stops and the twin decides: the pages' paths and layout; only a super admin may uninstall an
|
|
20
|
+
// app, cancel auto-renewal or delete the account (the page also admits billing admins, whom the twin does not model); a
|
|
21
|
+
// cancelled subscription ends at 00:00 UTC on its renewal date; the cancellation reasons offered; HubSpot's words for a
|
|
22
|
+
// refused deletion.
|
|
23
|
+
import { appById, installsIn, keep, subscriptionAt, type Row } from '../accounts.ts';
|
|
24
|
+
import { formOf, personFor, portalPage, who } from './page.tsx';
|
|
25
|
+
|
|
26
|
+
export async function accountPages(request: Request, root: string | undefined, at: string): Promise<Response | undefined> {
|
|
27
|
+
const path = new URL(request.url).pathname.replace(/\/+$/, '') || '/';
|
|
28
|
+
if (path === '/' && request.method === 'GET') {
|
|
29
|
+
const got = personFor(request, undefined, root);
|
|
30
|
+
if (got instanceof Response) return got;
|
|
31
|
+
const hub = Number(got.person.hubId);
|
|
32
|
+
const links: Array<[string, string]> = [['Contacts', `/contacts/${hub}/objects/0-1/views/all/list`], ['Companies', `/contacts/${hub}/objects/0-2/views/all/list`],
|
|
33
|
+
['Forms', `/forms/${hub}`], ['Deal pipelines', `/settings/${hub}/objects/deals/pipelines`], ['Connected apps', `/connected-apps/${hub}/installed`], ['Account & billing', `/account-and-billing/${hub}/subscriptions`]];
|
|
34
|
+
return portalPage('Home', { merchant: who(got.account, got.person), sections: [{ heading: `${String(got.account.name)} · Hub ID ${hub}`, items: links.map(([title, href]) => ({ title, actions: [{ label: 'Open', action: href, method: 'get' as const }] })) }] });
|
|
35
|
+
}
|
|
36
|
+
const m = /^\/(connected-apps|account-and-billing)\/(\d+)(?:\/(installed|installed\/uninstall|subscriptions|subscriptions\/cancel-auto-renewal|delete-account|delete-account\/delete|delete-account\/confirm))?$/.exec(path);
|
|
37
|
+
if (!m) return undefined;
|
|
38
|
+
const hub = Number(m[2]);
|
|
39
|
+
const got = personFor(request, hub, root);
|
|
40
|
+
if (got instanceof Response) return got;
|
|
41
|
+
const { person, account } = got;
|
|
42
|
+
const actor = String(person.email);
|
|
43
|
+
const admin = person.superAdmin === true;
|
|
44
|
+
if (m[1] === 'connected-apps') {
|
|
45
|
+
let notice: string | undefined;
|
|
46
|
+
if (m[3] === 'installed/uninstall' && request.method === 'POST') {
|
|
47
|
+
const f = await formOf(request);
|
|
48
|
+
const appId = Number(f.appId);
|
|
49
|
+
const install = installsIn(hub, root).find((i) => i.appId === appId);
|
|
50
|
+
const name = String(appById(appId, root)?.name ?? appId);
|
|
51
|
+
if (!admin) notice = 'Only a super admin can uninstall apps.';
|
|
52
|
+
else if (!install) notice = 'This app is not installed.';
|
|
53
|
+
else if (f.confirm !== 'uninstall') {
|
|
54
|
+
return portalPage(`Uninstall ${name}`, { merchant: who(account, person), notice: `Uninstall ${name}? Its access to this account ends.`, sections: [],
|
|
55
|
+
forms: [{ heading: `Uninstall ${name}`, action: `/connected-apps/${hub}/installed/uninstall`, submit: { label: 'Uninstall' },
|
|
56
|
+
fields: [{ id: 'appId', label: '', type: 'hidden', value: String(appId) }, { id: 'confirm', label: 'Type uninstall' }] }] }, f.confirm === undefined ? 200 : 422);
|
|
57
|
+
} else {
|
|
58
|
+
await keep('_install', String(install.id), { ...ownOf(install), uninstalled_at: at, uninstalled_by: actor }, root, at, actor);
|
|
59
|
+
notice = `${name} was uninstalled.`;
|
|
60
|
+
}
|
|
61
|
+
} else if (m[3] !== 'installed' || request.method !== 'GET') return undefined;
|
|
62
|
+
return portalPage('Connected apps', { merchant: who(account, person), ...(notice ? { notice } : {}), sections: [{ heading: 'Connected apps', empty: 'No apps are connected to this account.',
|
|
63
|
+
items: installsIn(hub, root).map((i) => ({ title: String(appById(Number(i.appId), root)?.name ?? i.appId), note: `Installed ${String(i.installed_at).slice(0, 10)} by ${String(i.installed_by ?? '')}`,
|
|
64
|
+
actions: [{ label: 'Uninstall', action: `/connected-apps/${hub}/installed/uninstall`, tone: 'danger' as const, fields: { appId: String(i.appId) } }] })) }] });
|
|
65
|
+
}
|
|
66
|
+
const billing = `/account-and-billing/${hub}`;
|
|
67
|
+
const tab = m[3];
|
|
68
|
+
if (tab === undefined) return request.method === 'GET' ? subscriptionsPage(hub, account, person, at) : undefined;
|
|
69
|
+
if (tab === 'subscriptions' && request.method === 'GET') return subscriptionsPage(hub, account, person, at);
|
|
70
|
+
if (tab === 'subscriptions/cancel-auto-renewal' && request.method === 'POST') {
|
|
71
|
+
const f = await formOf(request);
|
|
72
|
+
const paid = subscriptionAt(account, at);
|
|
73
|
+
if (!admin) return subscriptionsPage(hub, account, person, at, 'Only a super admin can cancel auto-renewal.', 403);
|
|
74
|
+
if (paid === 'free' || account.auto_renew === false) return subscriptionsPage(hub, account, person, at, 'No subscription renews automatically.', 422);
|
|
75
|
+
if (f.product !== paid || !f.reason) {
|
|
76
|
+
return portalPage('Cancel auto-renewal', { merchant: who(account, person), ...(f.product !== undefined ? { notice: 'Select the products to cancel and a cancellation reason.' } : {}),
|
|
77
|
+
sections: [{ heading: 'Features you lose', items: [{ title: paid, detail: `Your account will be downgraded to HubSpot's free tools on ${String(account.renews_on)}.` }] }],
|
|
78
|
+
forms: [{ heading: 'Cancel auto-renewal', action: `${billing}/subscriptions/cancel-auto-renewal`, submit: { label: 'Next' }, fields: [
|
|
79
|
+
{ id: 'product', label: 'Products to cancel', options: [{ value: paid, label: paid }] },
|
|
80
|
+
{ id: 'reason', label: 'Cancellation reason', options: REASONS.map((r) => ({ value: r, label: r })) },
|
|
81
|
+
] }] }, f.product === undefined ? 200 : 422);
|
|
82
|
+
}
|
|
83
|
+
if (f.confirm !== 'yes') {
|
|
84
|
+
return portalPage('Cancel auto-renewal', { merchant: who(account, person), notice: `${paid} will be canceled on ${String(account.renews_on)}, the end of its commitment term. Reason: ${f.reason}.`, sections: [],
|
|
85
|
+
forms: [{ heading: 'Review the cancellation', action: `${billing}/subscriptions/cancel-auto-renewal`, submit: { label: 'Cancel auto-renewal' }, fields: [
|
|
86
|
+
{ id: 'product', label: '', type: 'hidden', value: paid }, { id: 'reason', label: '', type: 'hidden', value: f.reason }, { id: 'confirm', label: '', type: 'hidden', value: 'yes' },
|
|
87
|
+
] }] });
|
|
88
|
+
}
|
|
89
|
+
await keep('_account', String(account.id), { ...ownOf(account), auto_renew: false, cancellation_reason: f.reason, auto_renew_cancelled_at: at, auto_renew_cancelled_by: actor }, root, at, actor);
|
|
90
|
+
return subscriptionsPage(hub, { ...account, auto_renew: false }, person, at, `Auto-renewal canceled. ${paid} ends on ${String(account.renews_on)}; a notice of non-renewal was sent to the billing admin.`);
|
|
91
|
+
}
|
|
92
|
+
if (tab === 'delete-account' && request.method === 'GET') return deletePage(hub, account, person);
|
|
93
|
+
if ((tab === 'delete-account/delete' || tab === 'delete-account/confirm') && request.method === 'POST') {
|
|
94
|
+
const paid = subscriptionAt(account, at);
|
|
95
|
+
if (!admin) return deletePage(hub, account, person, 'Only a super admin can delete this account.', 403);
|
|
96
|
+
if (paid !== 'free') return deletePage(hub, account, person, `You cannot delete your account while ${paid} still has an active commitment (it renews on ${String(account.renews_on)}).`, 422);
|
|
97
|
+
const typed = ((await formOf(request)).hubId ?? '').trim();
|
|
98
|
+
if (tab === 'delete-account/delete' || typed !== String(hub)) {
|
|
99
|
+
return portalPage('Delete account', { merchant: who(account, person), ...(tab === 'delete-account/confirm' ? { notice: 'Enter this account\'s Hub ID to confirm.' } : {}), sections: [],
|
|
100
|
+
forms: [{ heading: `Delete ${String(account.name)}?`, action: `${billing}/delete-account/confirm`, submit: { label: 'Yes, delete' }, fields: [{ id: 'hubId', label: 'Enter your Hub ID to confirm' }] }] }, tab === 'delete-account/confirm' ? 422 : 200);
|
|
101
|
+
}
|
|
102
|
+
await keep('_account', String(account.id), { ...ownOf(account), closed_at: at, closed_by: actor }, root, at, actor);
|
|
103
|
+
return portalPage('Account deleted', { merchant: String(account.name), notice: `${String(account.name)} (Hub ID ${hub}) has been deleted.`, sections: [] });
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const REASONS = ['Business is closing or merging', 'Moving to another product', 'Too expensive', 'Not using it enough', 'Other'];
|
|
109
|
+
|
|
110
|
+
function subscriptionsPage(hub: number, account: Row, person: Row, at: string, notice?: string, status = 200): Response {
|
|
111
|
+
const now = subscriptionAt(account, at);
|
|
112
|
+
const billing = `/account-and-billing/${hub}`;
|
|
113
|
+
return portalPage('Account & billing', { merchant: who(account, person), back: { href: `${billing}/delete-account`, label: 'Delete Account' }, ...(notice ? { notice } : {}),
|
|
114
|
+
sections: [{ heading: 'Subscriptions', items: [{ title: now === 'free' ? "HubSpot's free tools" : now, detail: `Hub ID ${hub}`,
|
|
115
|
+
...(now !== 'free' ? { note: account.auto_renew === false ? `Canceled: ends on ${String(account.renews_on)}` : `Renews automatically on ${String(account.renews_on)}` } : {}),
|
|
116
|
+
...(now !== 'free' && account.auto_renew !== false ? { actions: [{ label: 'Cancel auto-renewal', action: `${billing}/subscriptions/cancel-auto-renewal` }] } : {}) }] }] }, status);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function deletePage(hub: number, account: Row, person: Row, notice?: string, status = 200): Response {
|
|
120
|
+
return portalPage('Delete Account', { merchant: who(account, person), back: { href: `/account-and-billing/${hub}/subscriptions`, label: 'Subscriptions' }, ...(notice ? { notice } : {}),
|
|
121
|
+
sections: [{ heading: 'Delete Account', items: [{ title: String(account.name), detail: `Hub ID ${hub}`, note: 'Deleting the account permanently removes it and its data.',
|
|
122
|
+
actions: [{ label: 'Delete account', action: `/account-and-billing/${hub}/delete-account/delete`, tone: 'danger' as const }] }] }] }, status);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A stored row's own fields, without the kernel's. */
|
|
126
|
+
function ownOf(r: Row): Row {
|
|
127
|
+
const { id: _id, type: _type, updatedAt: _u, deleted: _d, ...rest } = r;
|
|
128
|
+
return rest;
|
|
129
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// THE CRM PAGES PEOPLE WORK IN — an account's contacts, a contact's record (where a rep creates a deal for the contact),
|
|
2
|
+
// a deal's record (where a rep moves its stage), its companies (where an account manager adds one), and its deal
|
|
3
|
+
// pipelines' settings (where a stage's internal ID is read). HubSpot's record pages live at
|
|
4
|
+
// app.hubspot.com/contacts/:hubId/record/:objectTypeId/:id; the twin builds only the acts the life takes on them, from
|
|
5
|
+
// @volter/world-ui's portal piece under HubSpot's skin. Every act is the request handler's own operation, in the
|
|
6
|
+
// account, as the signed-in user (portal.ts: `CRM_UI`, `userId:<id>`), so its webhook events are the API's.
|
|
7
|
+
//
|
|
8
|
+
// Where the documentation stops and the twin decides: the pages' layout and paths other than a record's; a deal made
|
|
9
|
+
// on a contact's record is associated with it (HubSpot's default deal-to-contact association, type 3) and owned by the
|
|
10
|
+
// rep who made it; the pipelines settings page is at /settings/:hubId/objects/deals/pipelines and shows each stage's
|
|
11
|
+
// internal ID beside its label; a pipeline made there takes its stages in order, separated by commas, and names its closed won and closed
|
|
12
|
+
// lost stages.
|
|
13
|
+
import { handleHubspotTwinRequest } from '../hubspot-twin.ts';
|
|
14
|
+
import { inPortal } from '../portal.ts';
|
|
15
|
+
import type { Row } from '../accounts.ts';
|
|
16
|
+
import { formOf, personFor, portalPage, redirect, who } from './page.tsx';
|
|
17
|
+
|
|
18
|
+
type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: Row }>;
|
|
19
|
+
|
|
20
|
+
function caller(hub: number, person: Row, root: string | undefined, at: string): Call {
|
|
21
|
+
return async (method, path, body) => {
|
|
22
|
+
const res = await inPortal({ hub, source: 'CRM_UI', sourceId: `userId:${String(person.userId)}` }, () =>
|
|
23
|
+
handleHubspotTwinRequest({ method, path, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), root, occurredAt: at }));
|
|
24
|
+
return { status: res.status, body: (res.body ?? {}) as Row };
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const CONTACT_PROPS = ['email', 'firstname', 'lastname', 'lifecyclestage', 'dub_id', 'dub_link', 'dub_partner_email'];
|
|
29
|
+
const nameOf = (p: Row): string => [p.firstname, p.lastname].filter(Boolean).join(' ') || String(p.email ?? '');
|
|
30
|
+
|
|
31
|
+
async function stageOptions(call: Call): Promise<Array<{ value: string; label: string }>> {
|
|
32
|
+
const pipelines = ((await call('GET', '/crm/v3/pipelines/deals')).body.results ?? []) as Row[];
|
|
33
|
+
return pipelines.flatMap((p) => ((p.stages ?? []) as Row[]).map((s) => ({ value: `${String(p.id)}/${String(s.id)}`, label: `${String(p.label)} · ${String(s.label)}` })));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** app.hubspot.com/contacts/… and /settings/…/pipelines, or undefined for another path. */
|
|
37
|
+
export async function crmPages(request: Request, root: string | undefined, at: string): Promise<Response | undefined> {
|
|
38
|
+
const url = new URL(request.url);
|
|
39
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
40
|
+
const m = /^\/(contacts|settings)\/(\d+)\/(.*)$/.exec(path);
|
|
41
|
+
if (!m) return undefined;
|
|
42
|
+
const hub = Number(m[2]);
|
|
43
|
+
const rest = m[3]!;
|
|
44
|
+
const got = personFor(request, hub, root);
|
|
45
|
+
if (got instanceof Response) return got;
|
|
46
|
+
const { person, account } = got;
|
|
47
|
+
const call = caller(hub, person, root, at);
|
|
48
|
+
const side = who(account, person);
|
|
49
|
+
const R = `/contacts/${hub}`;
|
|
50
|
+
|
|
51
|
+
if (m[1] === 'settings') {
|
|
52
|
+
if (rest !== 'objects/deals/pipelines') return undefined;
|
|
53
|
+
if (request.method === 'POST') {
|
|
54
|
+
const f = await formOf(request);
|
|
55
|
+
const labels = (f.stages ?? '').split(/\r?\n|,/).map((s) => s.trim()).filter(Boolean);
|
|
56
|
+
const stages = labels.map((label, i) => ({ label, displayOrder: i, metadata: label === f.won ? { isClosed: 'true', probability: '1.0' } : label === f.lost ? { isClosed: 'true', probability: '0.0' } : { isClosed: 'false', probability: String(Math.round(((i + 1) / (labels.length + 1)) * 10) / 10) } }));
|
|
57
|
+
const made = await call('POST', '/crm/v3/pipelines/deals', { label: f.label, displayOrder: 1, stages });
|
|
58
|
+
if (made.status !== 201) return pipelinesPage(call, side, hub, String(made.body.message ?? 'The pipeline could not be created.'), 422);
|
|
59
|
+
return pipelinesPage(call, side, hub, `Pipeline ${String(f.label)} created.`);
|
|
60
|
+
}
|
|
61
|
+
return pipelinesPage(call, side, hub);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (rest === 'objects/0-1/views/all/list' && request.method === 'GET') {
|
|
65
|
+
const list = ((await call('GET', `/crm/v3/objects/contacts?limit=100&properties=${CONTACT_PROPS.join(',')}`)).body.results ?? []) as Row[];
|
|
66
|
+
return portalPage('Contacts', { merchant: side, sections: [{ heading: 'Contacts', empty: 'No contacts yet.', items: list.map((c) => {
|
|
67
|
+
const p = c.properties as Row;
|
|
68
|
+
return { title: nameOf(p), detail: String(p.email ?? ''), note: `Record ID ${String(c.id)}`, actions: [{ label: 'Open', action: `${R}/record/0-1/${String(c.id)}`, method: 'get' as const }] };
|
|
69
|
+
}) }] });
|
|
70
|
+
}
|
|
71
|
+
if (rest === 'objects/0-2/views/all/list') {
|
|
72
|
+
let notice: string | undefined;
|
|
73
|
+
if (request.method === 'POST') {
|
|
74
|
+
const f = await formOf(request);
|
|
75
|
+
const properties = Object.fromEntries(Object.entries(f).filter(([, v]) => v !== ''));
|
|
76
|
+
const made = await call('POST', '/crm/v3/objects/companies', { properties });
|
|
77
|
+
notice = made.status === 201 ? `Company ${String(f.name)} created.` : String(made.body.message ?? 'The company could not be created.');
|
|
78
|
+
}
|
|
79
|
+
const list = ((await call('GET', '/crm/v3/objects/companies?limit=100')).body.results ?? []) as Row[];
|
|
80
|
+
const owners = ((await call('GET', '/crm/v3/owners')).body.results ?? []) as Row[];
|
|
81
|
+
const custom = ((await call('GET', '/crm/v3/properties/companies')).body.results ?? []) as Row[];
|
|
82
|
+
return portalPage('Companies', { merchant: side, ...(notice ? { notice } : {}),
|
|
83
|
+
sections: [{ heading: 'Companies', empty: 'No companies yet.', items: list.map((c) => ({ title: String((c.properties as Row).name ?? ''), detail: String((c.properties as Row).domain ?? ''), note: `Record ID ${String(c.id)}` })) }],
|
|
84
|
+
forms: [{ heading: 'Create company', action: `${R}/objects/0-2/views/all/list`, submit: { label: 'Create' }, fields: [
|
|
85
|
+
{ id: 'name', label: 'Company name' }, { id: 'domain', label: 'Company domain name' },
|
|
86
|
+
{ id: 'hubspot_owner_id', label: 'Company owner', options: owners.map((o) => ({ value: String(o.id), label: `${String(o.firstName)} ${String(o.lastName)}` })) },
|
|
87
|
+
...custom.filter((p) => p.hubspotDefined !== true && p.archived !== true).map((p) => ({ id: String(p.name), label: String(p.label) })),
|
|
88
|
+
] }] });
|
|
89
|
+
}
|
|
90
|
+
const rec = /^record\/(0-1|0-3)\/(\d+)(?:\/(deals|stage))?$/.exec(rest);
|
|
91
|
+
if (!rec) return undefined;
|
|
92
|
+
const [, type, id, act] = rec;
|
|
93
|
+
if (type === '0-1') {
|
|
94
|
+
if (act === 'deals' && request.method === 'POST') {
|
|
95
|
+
const f = await formOf(request);
|
|
96
|
+
const [pipeline, dealstage] = (f.stage ?? '').split('/');
|
|
97
|
+
const made = await call('POST', '/crm/v3/objects/0-3', {
|
|
98
|
+
properties: { dealname: f.dealname, amount: f.amount, pipeline, dealstage, hubspot_owner_id: String(person.ownerId) },
|
|
99
|
+
associations: [{ to: { id }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 3 }] }],
|
|
100
|
+
});
|
|
101
|
+
if (made.status !== 201) return contactPage(call, side, hub, id!, String(made.body.message ?? 'The deal could not be created.'), 422);
|
|
102
|
+
return redirect(`${R}/record/0-3/${String(made.body.id)}`);
|
|
103
|
+
}
|
|
104
|
+
if (act || request.method !== 'GET') return undefined;
|
|
105
|
+
return contactPage(call, side, hub, id!);
|
|
106
|
+
}
|
|
107
|
+
if (act === 'stage' && request.method === 'POST') {
|
|
108
|
+
const f = await formOf(request);
|
|
109
|
+
const [pipeline, dealstage] = (f.stage ?? '').split('/');
|
|
110
|
+
const moved = await call('PATCH', `/crm/v3/objects/0-3/${id!}`, { properties: { pipeline, dealstage } });
|
|
111
|
+
if (moved.status !== 200) return dealPage(call, side, hub, id!, String(moved.body.message ?? 'The deal could not be saved.'), 422);
|
|
112
|
+
return redirect(`${R}/record/0-3/${id!}`);
|
|
113
|
+
}
|
|
114
|
+
if (act || request.method !== 'GET') return undefined;
|
|
115
|
+
return dealPage(call, side, hub, id!);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function contactPage(call: Call, side: string, hub: number, id: string, notice?: string, status = 200): Promise<Response> {
|
|
119
|
+
const got = await call('GET', `/crm/v3/objects/contacts/${id}?properties=${CONTACT_PROPS.join(',')}&associations=deals`);
|
|
120
|
+
if (got.status !== 200) return portalPage('Contact', { merchant: side, notice: 'This record does not exist.', sections: [] }, 404);
|
|
121
|
+
const p = got.body.properties as Row;
|
|
122
|
+
const deals = (((got.body.associations as Row | undefined)?.deals as Row | undefined)?.results ?? []) as Row[];
|
|
123
|
+
return portalPage(nameOf(p), { merchant: side, back: { href: `/contacts/${hub}/objects/0-1/views/all/list`, label: 'Contacts' }, ...(notice ? { notice } : {}), sections: [
|
|
124
|
+
{ heading: 'About this contact', items: CONTACT_PROPS.filter((k) => p[k] !== undefined && p[k] !== null).map((k) => ({ title: k, detail: String(p[k]) })) },
|
|
125
|
+
{ heading: 'Deals', empty: 'No deals.', items: deals.map((d) => ({ title: `Deal ${String(d.id)}`, actions: [{ label: 'Open', action: `/contacts/${hub}/record/0-3/${String(d.id)}`, method: 'get' as const }] })) },
|
|
126
|
+
], forms: [{ heading: 'Create deal', action: `/contacts/${hub}/record/0-1/${id}/deals`, submit: { label: 'Create' }, fields: [
|
|
127
|
+
{ id: 'dealname', label: 'Deal name' }, { id: 'amount', label: 'Amount' },
|
|
128
|
+
{ id: 'stage', label: 'Pipeline and deal stage', options: await stageOptions(call) },
|
|
129
|
+
] }] }, status);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function dealPage(call: Call, side: string, hub: number, id: string, notice?: string, status = 200): Promise<Response> {
|
|
133
|
+
const got = await call('GET', `/crm/v3/objects/0-3/${id}?properties=dealname,amount,pipeline,dealstage,closedate&associations=contacts`);
|
|
134
|
+
if (got.status !== 200) return portalPage('Deal', { merchant: side, notice: 'This record does not exist.', sections: [] }, 404);
|
|
135
|
+
const p = got.body.properties as Row;
|
|
136
|
+
const contacts = (((got.body.associations as Row | undefined)?.contacts as Row | undefined)?.results ?? []) as Row[];
|
|
137
|
+
return portalPage(String(p.dealname ?? 'Deal'), { merchant: side, ...(notice ? { notice } : {}), sections: [
|
|
138
|
+
{ heading: 'About this deal', items: ['dealname', 'amount', 'pipeline', 'dealstage'].map((k) => ({ title: k, detail: String(p[k] ?? '') })) },
|
|
139
|
+
{ heading: 'Contacts', empty: 'No contacts.', items: contacts.map((c) => ({ title: `Contact ${String(c.id)}`, actions: [{ label: 'Open', action: `/contacts/${hub}/record/0-1/${String(c.id)}`, method: 'get' as const }] })) },
|
|
140
|
+
], forms: [{ heading: 'Deal stage', action: `/contacts/${hub}/record/0-3/${id}/stage`, submit: { label: 'Save' }, fields: [
|
|
141
|
+
{ id: 'stage', label: 'Deal stage', value: `${String(p.pipeline)}/${String(p.dealstage)}`, options: await stageOptions(call) },
|
|
142
|
+
] }] }, status);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function pipelinesPage(call: Call, side: string, hub: number, notice?: string, status = 200): Promise<Response> {
|
|
146
|
+
const pipelines = ((await call('GET', '/crm/v3/pipelines/deals')).body.results ?? []) as Row[];
|
|
147
|
+
return portalPage('Deal pipelines', { merchant: side, ...(notice ? { notice } : {}),
|
|
148
|
+
sections: pipelines.map((p) => ({ heading: `${String(p.label)} (pipeline ID ${String(p.id)})`, items: ((p.stages ?? []) as Row[]).map((s) => ({
|
|
149
|
+
title: String(s.label), detail: `Stage ID: ${String(s.id)}`, note: (s.metadata as Row | undefined)?.isClosed === 'true' ? `Closed (probability ${String((s.metadata as Row).probability)})` : 'Open' })) })),
|
|
150
|
+
forms: [{ heading: 'Create pipeline', action: `/settings/${hub}/objects/deals/pipelines`, submit: { label: 'Create pipeline' }, fields: [
|
|
151
|
+
{ id: 'label', label: 'Pipeline name' }, { id: 'stages', label: 'Stages, in order, separated by commas' },
|
|
152
|
+
{ id: 'won', label: 'Closed won stage' }, { id: 'lost', label: 'Closed lost stage' },
|
|
153
|
+
] }] }, status);
|
|
154
|
+
}
|