@automate.ax/integration-contracts 0.120.0 → 0.121.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.
@@ -0,0 +1,147 @@
1
+ import { isEncodable, type Encodable } from "@automate.ax/codec"
2
+ import * as z from "zod"
3
+
4
+ const HUBSPOT_API_ORIGIN = "https://api.hubapi.com/"
5
+ const HUBSPOT_API_URL = new URL(HUBSPOT_API_ORIGIN)
6
+ const HUBSPOT_SECRET_SCHEMA = z.object({ accessToken: z.string().min(1) })
7
+ const HUBSPOT_ERROR_SCHEMA = z.looseObject({
8
+ category: z.string().optional(),
9
+ correlationId: z.string().optional(),
10
+ message: z.string().optional(),
11
+ subCategory: z.string().optional(),
12
+ })
13
+
14
+ export const HUBSPOT_VALUE_SCHEMA = z.custom<Encodable>(isEncodable, {
15
+ message: "Expected a codec-safe HubSpot value.",
16
+ })
17
+
18
+ interface HubSpotRequestOptions<TSchema extends z.ZodType> {
19
+ body?: Encodable
20
+ method?: "DELETE" | "GET" | "PATCH" | "POST" | "PUT"
21
+ query?: Record<
22
+ string,
23
+ boolean | number | string | readonly string[] | null | undefined
24
+ >
25
+ responseSchema: TSchema
26
+ }
27
+
28
+ /** Error returned by a rejected HubSpot API request. */
29
+ export class HubSpotApiError extends Error {
30
+ readonly category?: string
31
+ readonly correlationId?: string
32
+ readonly details: Encodable
33
+ readonly retryAfter?: string
34
+ readonly status: number
35
+ readonly subCategory?: string
36
+
37
+ /**
38
+ * Creates an error from one rejected HubSpot response.
39
+ *
40
+ * @param status - HTTP response status.
41
+ * @param details - Codec-safe provider response.
42
+ * @param retryAfter - Provider retry timing, when present.
43
+ */
44
+ constructor(status: number, details: Encodable, retryAfter?: string) {
45
+ const providerError = HUBSPOT_ERROR_SCHEMA.safeParse(details)
46
+ super(
47
+ providerError.success && providerError.data.message
48
+ ? `HubSpot API request failed (${status}): ${providerError.data.message}`
49
+ : `HubSpot API request failed (${status}).`,
50
+ )
51
+ this.name = "HubSpotApiError"
52
+ this.category = providerError.success
53
+ ? providerError.data.category
54
+ : undefined
55
+ this.correlationId = providerError.success
56
+ ? providerError.data.correlationId
57
+ : undefined
58
+ this.details = details
59
+ this.retryAfter = retryAfter
60
+ this.status = status
61
+ this.subCategory = providerError.success
62
+ ? providerError.data.subCategory
63
+ : undefined
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Creates a minimal authenticated HubSpot REST client.
69
+ *
70
+ * @param secret - Stored OAuth or private-app token.
71
+ */
72
+ export function getHubSpotApi(secret: unknown) {
73
+ const { accessToken } = HUBSPOT_SECRET_SCHEMA.parse(secret)
74
+
75
+ return {
76
+ /**
77
+ * Sends one request below HubSpot's API origin.
78
+ *
79
+ * @param path - Relative provider API path.
80
+ * @param options - Method, query, body, and response schema.
81
+ */
82
+ async request<TSchema extends z.ZodType>(
83
+ path: string,
84
+ options: HubSpotRequestOptions<TSchema>,
85
+ ): Promise<z.output<TSchema>> {
86
+ const normalizedPath = path.replace(/^\/+/, "")
87
+ if (
88
+ !normalizedPath ||
89
+ normalizedPath.includes("://") ||
90
+ normalizedPath.includes("\\")
91
+ ) {
92
+ throw new TypeError("HubSpot API paths must be relative.")
93
+ }
94
+
95
+ const url = new URL(normalizedPath, HUBSPOT_API_ORIGIN)
96
+ if (url.origin !== HUBSPOT_API_URL.origin) {
97
+ throw new TypeError("HubSpot API paths must remain on api.hubapi.com.")
98
+ }
99
+ for (const [key, value] of Object.entries(options.query ?? {})) {
100
+ if (value == null) continue
101
+ url.searchParams.set(
102
+ key,
103
+ Array.isArray(value) ? value.join(",") : String(value),
104
+ )
105
+ }
106
+
107
+ const headers = new Headers({
108
+ Accept: "application/json",
109
+ Authorization: `Bearer ${accessToken}`,
110
+ })
111
+ if (options.body !== undefined) {
112
+ headers.set("Content-Type", "application/json")
113
+ }
114
+ const response = await fetch(url, {
115
+ body:
116
+ options.body === undefined ? undefined : JSON.stringify(options.body),
117
+ headers,
118
+ method: options.method ?? "GET",
119
+ })
120
+ const text = await response.text()
121
+ const parsed = text ? parseJson(text) : undefined
122
+ if (response.ok && !text) return options.responseSchema.parse(undefined)
123
+ const encodable = HUBSPOT_VALUE_SCHEMA.safeParse(parsed)
124
+ if (!response.ok || !encodable.success) {
125
+ throw new HubSpotApiError(
126
+ response.status,
127
+ encodable.success ? encodable.data : { response: text },
128
+ response.headers.get("Retry-After") ?? undefined,
129
+ )
130
+ }
131
+ return options.responseSchema.parse(parsed)
132
+ },
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Parses a response body while preserving non-JSON error text.
138
+ *
139
+ * @param value - Raw response body.
140
+ */
141
+ function parseJson(value: string): unknown {
142
+ try {
143
+ return JSON.parse(value)
144
+ } catch {
145
+ return { response: value }
146
+ }
147
+ }
@@ -0,0 +1,193 @@
1
+ import * as z from "zod"
2
+
3
+ export const HUBSPOT_TRIGGER_CONFIG_SCHEMA = z.object({})
4
+
5
+ export const HUBSPOT_EVENT_ACTION_SCHEMA = z.enum([
6
+ "associationAdded",
7
+ "associationRemoved",
8
+ "created",
9
+ "deleted",
10
+ "merged",
11
+ "propertyChanged",
12
+ "restored",
13
+ ])
14
+
15
+ export const HUBSPOT_EVENT_OBJECT_SCHEMA = z.enum([
16
+ "company",
17
+ "contact",
18
+ "deal",
19
+ "ticket",
20
+ ])
21
+
22
+ const HUBSPOT_COMMON_EVENT_FIELDS = {
23
+ appId: z.number().int().positive().optional(),
24
+ attemptNumber: z.number().int().nonnegative(),
25
+ changeSource: z.string().optional(),
26
+ eventId: z.number().int(),
27
+ objectId: z.string(),
28
+ objectType: HUBSPOT_EVENT_OBJECT_SCHEMA,
29
+ occurredAt: z.date(),
30
+ portalId: z.number().int().positive(),
31
+ sourceId: z.string().optional(),
32
+ subscriptionId: z.number().int().positive(),
33
+ }
34
+ const HUBSPOT_ASSOCIATION_EVENT_FIELDS = {
35
+ associationCategory: z.string().optional(),
36
+ associationType: z.string(),
37
+ associationTypeId: z.number().int().positive().optional(),
38
+ fromObjectId: z.string(),
39
+ fromObjectTypeId: z.string().optional(),
40
+ isPrimaryAssociation: z.boolean().optional(),
41
+ toObjectId: z.string(),
42
+ toObjectTypeId: z.string().optional(),
43
+ }
44
+
45
+ export const HUBSPOT_CRM_EVENT_SCHEMA = z.discriminatedUnion("action", [
46
+ z.object({
47
+ ...HUBSPOT_COMMON_EVENT_FIELDS,
48
+ ...HUBSPOT_ASSOCIATION_EVENT_FIELDS,
49
+ action: z.literal("associationAdded"),
50
+ associationRemoved: z.literal(false),
51
+ }),
52
+ z.object({
53
+ ...HUBSPOT_COMMON_EVENT_FIELDS,
54
+ ...HUBSPOT_ASSOCIATION_EVENT_FIELDS,
55
+ action: z.literal("associationRemoved"),
56
+ associationRemoved: z.literal(true),
57
+ }),
58
+ z.object({ ...HUBSPOT_COMMON_EVENT_FIELDS, action: z.literal("created") }),
59
+ z.object({ ...HUBSPOT_COMMON_EVENT_FIELDS, action: z.literal("deleted") }),
60
+ z.object({
61
+ ...HUBSPOT_COMMON_EVENT_FIELDS,
62
+ action: z.literal("merged"),
63
+ mergedObjectIds: z.string().array().min(1),
64
+ newObjectId: z.string(),
65
+ numberOfPropertiesMoved: z.number().int().nonnegative().optional(),
66
+ }),
67
+ z.object({
68
+ ...HUBSPOT_COMMON_EVENT_FIELDS,
69
+ action: z.literal("propertyChanged"),
70
+ propertyName: z.string(),
71
+ propertyValue: z.string(),
72
+ }),
73
+ z.object({ ...HUBSPOT_COMMON_EVENT_FIELDS, action: z.literal("restored") }),
74
+ ])
75
+
76
+ export type HubSpotCrmEvent = z.output<typeof HUBSPOT_CRM_EVENT_SCHEMA>
77
+
78
+ type HubSpotEventAction = z.output<typeof HUBSPOT_EVENT_ACTION_SCHEMA>
79
+ type HubSpotEventObject = z.output<typeof HUBSPOT_EVENT_OBJECT_SCHEMA>
80
+
81
+ /** Every semantic HubSpot CRM trigger type. */
82
+ type HubSpotSemanticTriggerType =
83
+ `hubspot.${HubSpotEventObject}.${HubSpotEventAction}`
84
+ type HubSpotTriggerContract = {
85
+ readonly configSchema: typeof HUBSPOT_TRIGGER_CONFIG_SCHEMA
86
+ readonly eventSchema: z.ZodType<HubSpotCrmEvent>
87
+ }
88
+
89
+ /** Complete HubSpot broad and semantic trigger contract map. */
90
+ type HubSpotTriggerContracts = {
91
+ readonly [TType in HubSpotSemanticTriggerType]: HubSpotTriggerContract
92
+ } & {
93
+ readonly "hubspot.crm.event": HubSpotTriggerContract
94
+ }
95
+
96
+ /** Every broad and semantic HubSpot trigger contract. */
97
+ export const hubspotTriggerContracts = {
98
+ "hubspot.company.associationAdded": semanticContract(
99
+ "company",
100
+ "associationAdded",
101
+ ),
102
+ "hubspot.company.associationRemoved": semanticContract(
103
+ "company",
104
+ "associationRemoved",
105
+ ),
106
+ "hubspot.company.created": semanticContract("company", "created"),
107
+ "hubspot.company.deleted": semanticContract("company", "deleted"),
108
+ "hubspot.company.merged": semanticContract("company", "merged"),
109
+ "hubspot.company.propertyChanged": semanticContract(
110
+ "company",
111
+ "propertyChanged",
112
+ ),
113
+ "hubspot.company.restored": semanticContract("company", "restored"),
114
+ "hubspot.contact.associationAdded": semanticContract(
115
+ "contact",
116
+ "associationAdded",
117
+ ),
118
+ "hubspot.contact.associationRemoved": semanticContract(
119
+ "contact",
120
+ "associationRemoved",
121
+ ),
122
+ "hubspot.contact.created": semanticContract("contact", "created"),
123
+ "hubspot.contact.deleted": semanticContract("contact", "deleted"),
124
+ "hubspot.contact.merged": semanticContract("contact", "merged"),
125
+ "hubspot.contact.propertyChanged": semanticContract(
126
+ "contact",
127
+ "propertyChanged",
128
+ ),
129
+ "hubspot.contact.restored": semanticContract("contact", "restored"),
130
+ "hubspot.crm.event": {
131
+ configSchema: HUBSPOT_TRIGGER_CONFIG_SCHEMA,
132
+ eventSchema: HUBSPOT_CRM_EVENT_SCHEMA,
133
+ },
134
+ "hubspot.deal.associationAdded": semanticContract("deal", "associationAdded"),
135
+ "hubspot.deal.associationRemoved": semanticContract(
136
+ "deal",
137
+ "associationRemoved",
138
+ ),
139
+ "hubspot.deal.created": semanticContract("deal", "created"),
140
+ "hubspot.deal.deleted": semanticContract("deal", "deleted"),
141
+ "hubspot.deal.merged": semanticContract("deal", "merged"),
142
+ "hubspot.deal.propertyChanged": semanticContract("deal", "propertyChanged"),
143
+ "hubspot.deal.restored": semanticContract("deal", "restored"),
144
+ "hubspot.ticket.associationAdded": semanticContract(
145
+ "ticket",
146
+ "associationAdded",
147
+ ),
148
+ "hubspot.ticket.associationRemoved": semanticContract(
149
+ "ticket",
150
+ "associationRemoved",
151
+ ),
152
+ "hubspot.ticket.created": semanticContract("ticket", "created"),
153
+ "hubspot.ticket.deleted": semanticContract("ticket", "deleted"),
154
+ "hubspot.ticket.merged": semanticContract("ticket", "merged"),
155
+ "hubspot.ticket.propertyChanged": semanticContract(
156
+ "ticket",
157
+ "propertyChanged",
158
+ ),
159
+ "hubspot.ticket.restored": semanticContract("ticket", "restored"),
160
+ } satisfies HubSpotTriggerContracts
161
+
162
+ /**
163
+ * Builds one semantic HubSpot lifecycle contract.
164
+ *
165
+ * @param objectType - Normalized CRM object type.
166
+ * @param action - Normalized CRM lifecycle action.
167
+ */
168
+ function semanticContract<
169
+ const TObjectType extends HubSpotEventObject,
170
+ const TAction extends HubSpotEventAction,
171
+ >(objectType: TObjectType, action: TAction) {
172
+ return {
173
+ configSchema: HUBSPOT_TRIGGER_CONFIG_SCHEMA,
174
+ eventSchema: HUBSPOT_CRM_EVENT_SCHEMA.and(
175
+ z.object({
176
+ action: z.literal(action),
177
+ objectType: z.literal(objectType),
178
+ }),
179
+ ),
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Returns broad and semantic event types for one normalized callback.
185
+ *
186
+ * @param event - Normalized HubSpot CRM event.
187
+ */
188
+ export function getHubSpotEventTypes(event: HubSpotCrmEvent) {
189
+ return [
190
+ `hubspot.${event.objectType}.${event.action}`,
191
+ "hubspot.crm.event",
192
+ ] as const
193
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./api"
2
+ export * from "./events"
3
+ export * from "./schemas"
@@ -0,0 +1,189 @@
1
+ import * as z from "zod"
2
+
3
+ export const HUBSPOT_CORE_OBJECT_TYPE_SCHEMA = z.enum([
4
+ "companies",
5
+ "contacts",
6
+ "deals",
7
+ "tickets",
8
+ ])
9
+
10
+ export const HUBSPOT_DATE_SCHEMA = z.iso
11
+ .datetime({ offset: true })
12
+ .transform((value) => new Date(value))
13
+
14
+ export const HUBSPOT_PROPERTY_VALUE_SCHEMA = z.object({
15
+ /** HubSpot internal property name. */
16
+ name: z.string().trim().min(1),
17
+
18
+ /** Serialized property value. An empty string clears a writable property. */
19
+ value: z.string().nullable(),
20
+ })
21
+
22
+ export const HUBSPOT_PROPERTY_VALUES_SCHEMA =
23
+ HUBSPOT_PROPERTY_VALUE_SCHEMA.array().superRefine((properties, context) => {
24
+ const seen = new Set<string>()
25
+ for (const [index, property] of properties.entries()) {
26
+ if (seen.has(property.name)) {
27
+ context.addIssue({
28
+ code: "custom",
29
+ message: `Duplicate HubSpot property: ${property.name}`,
30
+ path: [index, "name"],
31
+ })
32
+ }
33
+ seen.add(property.name)
34
+ }
35
+ })
36
+
37
+ export const HUBSPOT_ASSOCIATION_TYPE_SCHEMA = z.object({
38
+ associationCategory: z.enum([
39
+ "HUBSPOT_DEFINED",
40
+ "INTEGRATOR_DEFINED",
41
+ "USER_DEFINED",
42
+ ]),
43
+ associationTypeId: z.number().int().positive(),
44
+ })
45
+
46
+ export const HUBSPOT_ASSOCIATION_INPUT_SCHEMA = z.object({
47
+ toRecordId: z.string().trim().min(1),
48
+ types: HUBSPOT_ASSOCIATION_TYPE_SCHEMA.array().min(1),
49
+ })
50
+
51
+ export const HUBSPOT_ASSOCIATED_RECORD_SCHEMA = z.object({
52
+ id: z.string(),
53
+ type: z.string().optional(),
54
+ })
55
+
56
+ export const HUBSPOT_ASSOCIATION_RESULT_SCHEMA = z.object({
57
+ objectType: z.string(),
58
+ records: HUBSPOT_ASSOCIATED_RECORD_SCHEMA.array(),
59
+ })
60
+
61
+ export const HUBSPOT_PROPERTY_HISTORY_VALUE_SCHEMA = z.object({
62
+ sourceId: z.string().optional(),
63
+ sourceType: z.string(),
64
+ timestamp: HUBSPOT_DATE_SCHEMA,
65
+ updatedByUserId: z.number().int().optional(),
66
+ value: z.string().nullable(),
67
+ })
68
+
69
+ export const HUBSPOT_PROPERTY_HISTORY_SCHEMA = z.object({
70
+ name: z.string(),
71
+ values: HUBSPOT_PROPERTY_HISTORY_VALUE_SCHEMA.array(),
72
+ })
73
+
74
+ export const HUBSPOT_RECORD_SCHEMA = z.object({
75
+ archived: z.boolean(),
76
+ archivedAt: HUBSPOT_DATE_SCHEMA.optional(),
77
+ associations: HUBSPOT_ASSOCIATION_RESULT_SCHEMA.array().prefault([]),
78
+ createdAt: HUBSPOT_DATE_SCHEMA,
79
+ id: z.string(),
80
+ properties: HUBSPOT_PROPERTY_VALUES_SCHEMA,
81
+ propertiesWithHistory: HUBSPOT_PROPERTY_HISTORY_SCHEMA.array().prefault([]),
82
+ updatedAt: HUBSPOT_DATE_SCHEMA,
83
+ })
84
+
85
+ export const HUBSPOT_PAGE_INFO_SCHEMA = z.object({
86
+ after: z.string().optional(),
87
+ })
88
+
89
+ export const HUBSPOT_RECORD_PAGE_SCHEMA = z.object({
90
+ pageInfo: HUBSPOT_PAGE_INFO_SCHEMA,
91
+ records: HUBSPOT_RECORD_SCHEMA.array(),
92
+ total: z.number().int().nonnegative().optional(),
93
+ })
94
+
95
+ const HUBSPOT_PROVIDER_HISTORY_VALUE_SCHEMA = z.looseObject({
96
+ sourceId: z.string().optional(),
97
+ sourceType: z.string(),
98
+ timestamp: z.iso.datetime({ offset: true }),
99
+ updatedByUserId: z.number().int().optional(),
100
+ value: z.string().nullable(),
101
+ })
102
+
103
+ export const HUBSPOT_PROVIDER_RECORD_SCHEMA = z.looseObject({
104
+ archived: z.boolean(),
105
+ archivedAt: z.iso.datetime({ offset: true }).optional(),
106
+ associations: z
107
+ .record(
108
+ z.string(),
109
+ z.looseObject({
110
+ results: z
111
+ .looseObject({ id: z.string(), type: z.string().optional() })
112
+ .array(),
113
+ }),
114
+ )
115
+ .optional(),
116
+ createdAt: z.iso.datetime({ offset: true }),
117
+ id: z.string(),
118
+ properties: z.record(z.string(), z.string().nullable()),
119
+ propertiesWithHistory: z
120
+ .record(z.string(), HUBSPOT_PROVIDER_HISTORY_VALUE_SCHEMA.array())
121
+ .optional(),
122
+ updatedAt: z.iso.datetime({ offset: true }),
123
+ })
124
+
125
+ export const HUBSPOT_PROVIDER_RECORD_PAGE_SCHEMA = z.looseObject({
126
+ paging: z
127
+ .looseObject({ next: z.looseObject({ after: z.string() }).optional() })
128
+ .optional(),
129
+ results: HUBSPOT_PROVIDER_RECORD_SCHEMA.array(),
130
+ total: z.number().int().nonnegative().optional(),
131
+ })
132
+
133
+ /**
134
+ * Converts HubSpot's dynamic property maps into structurally typed entries.
135
+ *
136
+ * @param record - Provider CRM record.
137
+ */
138
+ export function toHubSpotRecord(
139
+ record: z.output<typeof HUBSPOT_PROVIDER_RECORD_SCHEMA>,
140
+ ): z.input<typeof HUBSPOT_RECORD_SCHEMA> {
141
+ return {
142
+ archived: record.archived,
143
+ ...(record.archivedAt ? { archivedAt: record.archivedAt } : {}),
144
+ associations: Object.entries(record.associations ?? {}).map(
145
+ ([objectType, association]) => ({
146
+ objectType,
147
+ records: association.results,
148
+ }),
149
+ ),
150
+ createdAt: record.createdAt,
151
+ id: record.id,
152
+ properties: Object.entries(record.properties).map(([name, value]) => ({
153
+ name,
154
+ value,
155
+ })),
156
+ propertiesWithHistory: Object.entries(
157
+ record.propertiesWithHistory ?? {},
158
+ ).map(([name, values]) => ({ name, values })),
159
+ updatedAt: record.updatedAt,
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Converts HubSpot's record-page envelope into public pagination.
165
+ *
166
+ * @param page - Provider record page.
167
+ */
168
+ export function toHubSpotRecordPage(
169
+ page: z.output<typeof HUBSPOT_PROVIDER_RECORD_PAGE_SCHEMA>,
170
+ ): z.input<typeof HUBSPOT_RECORD_PAGE_SCHEMA> {
171
+ return {
172
+ pageInfo: { after: page.paging?.next?.after },
173
+ records: page.results.map(toHubSpotRecord),
174
+ ...(page.total === undefined ? {} : { total: page.total }),
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Converts typed property entries to HubSpot's provider property map.
180
+ *
181
+ * @param properties - Public property entries.
182
+ */
183
+ export function toHubSpotProperties(
184
+ properties: z.output<typeof HUBSPOT_PROPERTY_VALUES_SCHEMA>,
185
+ ) {
186
+ return Object.fromEntries(
187
+ properties.map(({ name, value }) => [name, value ?? ""]),
188
+ )
189
+ }
package/src/triggers.ts CHANGED
@@ -11,6 +11,7 @@ import type { googleCalendarTriggerContracts } from "./google-calendar"
11
11
  import type { googleFormsTriggerContracts } from "./google-forms"
12
12
  import type { googleMeetTriggerContracts } from "./google-meet"
13
13
  import type { googleSheetsTriggerContracts } from "./google-sheets"
14
+ import type { hubspotTriggerContracts } from "./hubspot"
14
15
  import type { linearTriggerContracts } from "./linear"
15
16
  import type { millionVerifierTriggerContracts } from "./millionverifier"
16
17
  import type { notionTriggerContracts } from "./notion"
@@ -38,6 +39,7 @@ export type TriggerContractMap = typeof airtableTriggerContracts &
38
39
  typeof googleFormsTriggerContracts &
39
40
  typeof googleMeetTriggerContracts &
40
41
  typeof googleSheetsTriggerContracts &
42
+ typeof hubspotTriggerContracts &
41
43
  typeof linearTriggerContracts &
42
44
  typeof millionVerifierTriggerContracts &
43
45
  typeof notionTriggerContracts &