@wfgraph/plugins 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -0
- package/dist/client-BEcFnbOB.js +34 -0
- package/dist/client-C2NIQxhb.js +64 -0
- package/dist/client-DDDCNLId.js +58 -0
- package/dist/client-DJGI9f53.js +73 -0
- package/dist/client-DkPUX0ps.js +50 -0
- package/dist/errors-C4i6-DQE.js +77 -0
- package/dist/index.d.ts +886 -0
- package/dist/index.js +2236 -0
- package/dist/test-8wDSAiFQ.js +45 -0
- package/dist/test-B8I6nxMN.js +38 -0
- package/dist/test-DWVpiQNl.js +36 -0
- package/dist/test-Dsrib2EJ.js +32 -0
- package/dist/test-DzstoPiR.js +35 -0
- package/dist/test-S28s7Z3a.js +35 -0
- package/dist/ui.d.ts +73 -0
- package/dist/ui.js +1631 -0
- package/package.json +57 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2236 @@
|
|
|
1
|
+
import { n as createAcuityClient, r as createAcuitySdk, t as callAcuity } from "./client-DkPUX0ps.js";
|
|
2
|
+
import { n as getClerkApiErrorMessage, r as toClerkApiUser, t as createClerkBackendClient } from "./client-BEcFnbOB.js";
|
|
3
|
+
import { t as describeLinearFailure } from "./errors-C4i6-DQE.js";
|
|
4
|
+
import { i as sendResendEmail, t as describeResendFailure } from "./client-C2NIQxhb.js";
|
|
5
|
+
import { n as describeSlackFailure, t as callSlack } from "./client-DDDCNLId.js";
|
|
6
|
+
import { n as describeTwilioFailure, t as createTwilioMessage } from "./client-DJGI9f53.js";
|
|
7
|
+
import { StepFailure, defineIntegration, readAs } from "@wfgraph/core/plugin";
|
|
8
|
+
import { Effect, Result, Schema, SchemaTransformation } from "effect";
|
|
9
|
+
import { omitBy } from "es-toolkit/object";
|
|
10
|
+
import { isNil } from "es-toolkit/predicate";
|
|
11
|
+
import { LinearClient } from "@linear/sdk";
|
|
12
|
+
//#region src/acuity/payloads.ts
|
|
13
|
+
/**
|
|
14
|
+
* The wire shapes Acuity's API answers with.
|
|
15
|
+
*
|
|
16
|
+
* Modelled on what the API sends rather than on the SDK's types: the SDK casts the
|
|
17
|
+
* response without validating it, so these describe a system's JSON directly. This
|
|
18
|
+
* layer is owned by no single action -- every action whose output nests an
|
|
19
|
+
* appointment, an appointment type, or an availability slot reads from here.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* A field the SDK types as `unknown`, kept rather than trimmed.
|
|
23
|
+
*
|
|
24
|
+
* The encode that puts a step's answer on the wire keeps only what the schema
|
|
25
|
+
* admits, so a field left undeclared would not reach the run log at all. There is
|
|
26
|
+
* nothing to say about this one's shape, which is what `Unknown` says; encoding it
|
|
27
|
+
* still refuses a value that is not JSON, so a memoized step result stays safe.
|
|
28
|
+
* The picker has no path to offer for it and drops it, which is correct: nothing
|
|
29
|
+
* downstream can address a shape nobody described.
|
|
30
|
+
*/
|
|
31
|
+
const opaqueJson = Schema.optional(Schema.Unknown);
|
|
32
|
+
/**
|
|
33
|
+
* A whole number the editor offers, described so the picker can show it.
|
|
34
|
+
*
|
|
35
|
+
* A bare `Schema.Number` describes itself as a number or one of the strings
|
|
36
|
+
* "Infinity", "-Infinity" and "NaN", which the field reader cannot use, so a
|
|
37
|
+
* numeric field written without the check drops out of the derived list.
|
|
38
|
+
*/
|
|
39
|
+
function describedNumber(description) {
|
|
40
|
+
return Schema.Number.annotate({ description }).check(Schema.isFinite());
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A field of a system's payload, as an output schema may describe it.
|
|
44
|
+
*
|
|
45
|
+
* `optionalKey(NullOr(...))` is the one spelling that survives everything a system
|
|
46
|
+
* sends: an absent key, an explicit `null`, or the value. `optional(X)` refuses the
|
|
47
|
+
* null and `NullishOr(X)` refuses the absent key, and either refusal fails the
|
|
48
|
+
* encode and so the whole step. The picker reads the same `nullable` off this as it
|
|
49
|
+
* did off the stricter spellings, so nothing is lost by tolerating both.
|
|
50
|
+
*/
|
|
51
|
+
function externalField(field) {
|
|
52
|
+
return Schema.optionalKey(Schema.NullOr(field));
|
|
53
|
+
}
|
|
54
|
+
/** The same tolerance for a whole number the picker offers. */
|
|
55
|
+
function externalNumber(description) {
|
|
56
|
+
return externalField(describedNumber(description));
|
|
57
|
+
}
|
|
58
|
+
/** The same tolerance for a string the picker offers. */
|
|
59
|
+
function externalText(description) {
|
|
60
|
+
return externalField(Schema.String.annotate({ description }));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One answer a client gave on an intake form.
|
|
64
|
+
*
|
|
65
|
+
* Modelled on what the API sends rather than on the SDK's type: the SDK casts the
|
|
66
|
+
* response without validating it, and it is missing a level. `value` is text, a
|
|
67
|
+
* list of text, or nothing, which is one union too many for the picker to name a
|
|
68
|
+
* path for; it is declared anyway, because the encode keeps only what the schema
|
|
69
|
+
* admits and an answer nobody can address by path is still an answer somebody reads
|
|
70
|
+
* in the run log.
|
|
71
|
+
*/
|
|
72
|
+
const formAnswerSchema = Schema.Struct({
|
|
73
|
+
id: externalNumber("Answer ID"),
|
|
74
|
+
fieldID: externalNumber("Form field ID"),
|
|
75
|
+
name: externalText("Question the client answered"),
|
|
76
|
+
value: Schema.optionalKey(Schema.NullOr(Schema.Union([Schema.String, Schema.mutable(Schema.Array(Schema.String))])))
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* One intake form on an appointment, holding the answers given on it.
|
|
80
|
+
*
|
|
81
|
+
* The nesting is what the wire has and the SDK's type does not: `forms` is a list
|
|
82
|
+
* of forms, each carrying its own `values` list of answers. A schema describing
|
|
83
|
+
* those answers one level up fails its encode, and so fails the step, on every
|
|
84
|
+
* appointment that has an intake form.
|
|
85
|
+
*/
|
|
86
|
+
const appointmentFormSchema = Schema.Struct({
|
|
87
|
+
id: externalNumber("Form ID"),
|
|
88
|
+
name: externalText("Form name"),
|
|
89
|
+
values: Schema.optionalKey(Schema.NullOr(Schema.mutable(Schema.Array(formAnswerSchema)).annotate({ description: "Answers given on this form" })))
|
|
90
|
+
});
|
|
91
|
+
/**
|
|
92
|
+
* Acuity's appointment, as the API sends it.
|
|
93
|
+
*
|
|
94
|
+
* Two fields are required, because the actions read them to answer with and a
|
|
95
|
+
* payload without them is not an appointment: everything else is an `externalField`.
|
|
96
|
+
* That is deliberate rather than lazy. This describes somebody else's JSON, the SDK
|
|
97
|
+
* validates none of it, and a field this schema insists on that a real payload
|
|
98
|
+
* omits fails the encode and so fails the step.
|
|
99
|
+
*/
|
|
100
|
+
const appointmentSchema = Schema.Struct({
|
|
101
|
+
id: describedNumber("Appointment ID"),
|
|
102
|
+
datetime: Schema.String.annotate({ description: "Appointment start, ISO 8601 with offset" }),
|
|
103
|
+
firstName: externalText("Client first name"),
|
|
104
|
+
lastName: externalText("Client last name"),
|
|
105
|
+
email: externalText("Client email address"),
|
|
106
|
+
phone: externalText("Client phone number"),
|
|
107
|
+
date: externalText("Appointment date, as Acuity writes it for people"),
|
|
108
|
+
endDate: externalText("Appointment end date, as Acuity writes it for people"),
|
|
109
|
+
time: externalText("Appointment start time"),
|
|
110
|
+
endTime: externalText("Appointment end time"),
|
|
111
|
+
duration: externalText("Duration in minutes"),
|
|
112
|
+
timezone: externalText("The appointment's IANA timezone"),
|
|
113
|
+
calendarTimezone: externalText("IANA timezone of the calendar that owns it"),
|
|
114
|
+
type: externalText("Appointment type name"),
|
|
115
|
+
appointmentTypeID: externalNumber("Appointment type ID"),
|
|
116
|
+
calendar: externalText("Calendar name"),
|
|
117
|
+
calendarID: externalNumber("Calendar ID"),
|
|
118
|
+
price: Schema.optionalKey(Schema.NullOr(Schema.Union([Schema.String, Schema.Finite]))),
|
|
119
|
+
paid: externalText("Whether the appointment is paid"),
|
|
120
|
+
notes: externalText("Appointment notes"),
|
|
121
|
+
forms: Schema.optionalKey(Schema.NullOr(Schema.mutable(Schema.Array(appointmentFormSchema)).annotate({ description: "Intake forms on the appointment" }))),
|
|
122
|
+
noShow: externalField(Schema.Boolean.annotate({ description: "Marked as a no-show" })),
|
|
123
|
+
canceled: externalField(Schema.Boolean.annotate({ description: "Whether the appointment is canceled" })),
|
|
124
|
+
certificate: opaqueJson,
|
|
125
|
+
package: opaqueJson,
|
|
126
|
+
scheduledBy: externalText("Who scheduled it, or null for a client not logged in")
|
|
127
|
+
});
|
|
128
|
+
/**
|
|
129
|
+
* Acuity's appointment type, as the API sends it.
|
|
130
|
+
*
|
|
131
|
+
* Same rule as the appointment above: `id` is what identifies one, and everything
|
|
132
|
+
* else tolerates an absent key and an explicit null, because this describes a
|
|
133
|
+
* system's JSON. `price` arrives as a string on some types and a number on others,
|
|
134
|
+
* which the picker has no single type for and drops; it is declared so that the
|
|
135
|
+
* encode keeps it.
|
|
136
|
+
*/
|
|
137
|
+
const appointmentTypeSchema = Schema.Struct({
|
|
138
|
+
id: describedNumber("Appointment type ID"),
|
|
139
|
+
name: externalText("Appointment type name"),
|
|
140
|
+
active: externalField(Schema.Boolean.annotate({ description: "Whether the type is bookable" })),
|
|
141
|
+
description: externalText("Appointment type description"),
|
|
142
|
+
duration: externalNumber("Duration in minutes"),
|
|
143
|
+
category: externalText("Category name"),
|
|
144
|
+
color: externalText("Calendar colour"),
|
|
145
|
+
private: externalField(Schema.Boolean.annotate({ description: "Whether the type is hidden from the public scheduler" })),
|
|
146
|
+
type: externalField(Schema.Literals([
|
|
147
|
+
"service",
|
|
148
|
+
"class",
|
|
149
|
+
"series"
|
|
150
|
+
]).annotate({ description: "Kind of type: service, class, or series" })),
|
|
151
|
+
classSize: externalNumber("Seats in a class, if it is one"),
|
|
152
|
+
paddingAfter: externalNumber("Minutes of padding after the appointment"),
|
|
153
|
+
paddingBefore: externalNumber("Minutes of padding before the appointment"),
|
|
154
|
+
calendarIDs: Schema.optionalKey(Schema.NullOr(Schema.mutable(Schema.Array(Schema.Finite)).annotate({ description: "Calendars offering this type" }))),
|
|
155
|
+
price: Schema.optionalKey(Schema.NullOr(Schema.Union([Schema.String, Schema.Finite])))
|
|
156
|
+
});
|
|
157
|
+
const availabilityDateSchema = Schema.Struct({ date: Schema.String.annotate({ description: "Available date, YYYY-MM-DD" }) });
|
|
158
|
+
const availabilityTimeSlotSchema = Schema.Struct({ time: Schema.String.annotate({ description: "Bookable slot, ISO 8601 with offset" }) });
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/acuity/shared.ts
|
|
161
|
+
/**
|
|
162
|
+
* Turning what a config field holds into what Acuity's API takes.
|
|
163
|
+
*
|
|
164
|
+
* Acuity wants whole numbers, real booleans and a list of form answers where
|
|
165
|
+
* the editor gives a step text, so each of these reads one field and says, in
|
|
166
|
+
* the words the run log shows, what is wrong with it. They answer an `Effect`,
|
|
167
|
+
* so a step names the field and moves on -- `yield*` is the whole of the
|
|
168
|
+
* handling, where a result object needed a check and an early return after
|
|
169
|
+
* every single one.
|
|
170
|
+
*
|
|
171
|
+
* The messages are the reason the reading stays here rather than moving into
|
|
172
|
+
* the input schemas: a decode that fails inside `defineStep` reports itself as
|
|
173
|
+
* an invalid configuration for the action, and what an author needs to read is
|
|
174
|
+
* which box holds what. The two schema declarations at the top are the other
|
|
175
|
+
* half of the same boundary -- what an input schema says a config field may
|
|
176
|
+
* hold, before one of these parsers reads it.
|
|
177
|
+
*/
|
|
178
|
+
/** Every config field arrives as text, so this is what most of them look like. */
|
|
179
|
+
const optionalText = Schema.optionalKey(Schema.String);
|
|
180
|
+
/**
|
|
181
|
+
* A number a config field carries.
|
|
182
|
+
*
|
|
183
|
+
* A `number` config field may be stored as a number and a template resolves to
|
|
184
|
+
* text, so both arrive and the step parses whichever it got.
|
|
185
|
+
*/
|
|
186
|
+
const optionalNumeric = Schema.optionalKey(Schema.Union([Schema.String, Schema.Finite]));
|
|
187
|
+
/** A config value as text, or nothing when the field was left empty. */
|
|
188
|
+
function normalizeRawValue(value) {
|
|
189
|
+
if (value === void 0) return;
|
|
190
|
+
if (typeof value === "number") return String(value);
|
|
191
|
+
const trimmed = value.trim();
|
|
192
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
193
|
+
}
|
|
194
|
+
function positiveIntegerOr(normalized, fieldLabel) {
|
|
195
|
+
const parsed = Number(normalized);
|
|
196
|
+
return Number.isInteger(parsed) && parsed > 0 ? Effect.succeed(parsed) : Effect.fail(new StepFailure({ message: `${fieldLabel} must be a positive integer.` }));
|
|
197
|
+
}
|
|
198
|
+
function requiredInteger(value, fieldLabel) {
|
|
199
|
+
const normalized = normalizeRawValue(value);
|
|
200
|
+
return normalized ? positiveIntegerOr(normalized, fieldLabel) : Effect.fail(new StepFailure({ message: `${fieldLabel} is required.` }));
|
|
201
|
+
}
|
|
202
|
+
function optionalInteger(value, fieldLabel) {
|
|
203
|
+
const normalized = normalizeRawValue(value);
|
|
204
|
+
return normalized ? positiveIntegerOr(normalized, fieldLabel) : Effect.succeed(void 0);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* A select whose options are "", "true" and "false", read as the boolean Acuity
|
|
208
|
+
* takes. An empty choice is the absent one, which is how a step says "leave
|
|
209
|
+
* Acuity's own default alone".
|
|
210
|
+
*/
|
|
211
|
+
function optionalBoolean(value, fieldLabel) {
|
|
212
|
+
if (value === void 0 || value === "") return Effect.succeed(void 0);
|
|
213
|
+
const normalized = value.trim().toLowerCase();
|
|
214
|
+
if (normalized === "true") return Effect.succeed(true);
|
|
215
|
+
if (normalized === "false") return Effect.succeed(false);
|
|
216
|
+
return Effect.fail(new StepFailure({ message: `${fieldLabel} must be true, false, or empty.` }));
|
|
217
|
+
}
|
|
218
|
+
function optionalIntegerList(value, fieldLabel) {
|
|
219
|
+
const normalized = normalizeRawValue(value);
|
|
220
|
+
if (!normalized) return Effect.succeed(void 0);
|
|
221
|
+
const entries = normalized.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
222
|
+
if (entries.length === 0) return Effect.succeed(void 0);
|
|
223
|
+
const parsed = entries.map((entry) => Number(entry));
|
|
224
|
+
return parsed.some((entry) => !Number.isInteger(entry) || entry <= 0) ? Effect.fail(new StepFailure({ message: `${fieldLabel} must contain only positive integers (comma separated).` })) : Effect.succeed(parsed);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Acuity takes the answers to a booking form's custom questions as fieldID/value
|
|
228
|
+
* pairs. Workflow authors type those pairs as a JSON string into the node config,
|
|
229
|
+
* so this schema is the boundary between that text and the Acuity client.
|
|
230
|
+
*
|
|
231
|
+
* `errors: "all"` because what the decode says about the text is what the author
|
|
232
|
+
* reads back in the step's failure: naming one mistake at a time would send them
|
|
233
|
+
* round the loop once per mistake.
|
|
234
|
+
*/
|
|
235
|
+
const acuityCustomFieldsSchema = Schema.mutable(Schema.Array(Schema.Struct({
|
|
236
|
+
fieldID: Schema.Finite.check(Schema.isInt(), Schema.isGreaterThan(0)),
|
|
237
|
+
value: Schema.Union([Schema.String, Schema.mutable(Schema.Array(Schema.String))])
|
|
238
|
+
})));
|
|
239
|
+
const decodeCustomFields = Schema.decodeUnknownResult(acuityCustomFieldsSchema, { errors: "all" });
|
|
240
|
+
function optionalCustomFields(value) {
|
|
241
|
+
const normalized = normalizeRawValue(value);
|
|
242
|
+
if (!normalized) return Effect.succeed(void 0);
|
|
243
|
+
let parsed;
|
|
244
|
+
try {
|
|
245
|
+
parsed = JSON.parse(normalized);
|
|
246
|
+
} catch {
|
|
247
|
+
return Effect.fail(new StepFailure({ message: "Custom Fields JSON must be valid JSON in the format [{\"fieldID\":1234,\"value\":\"text\"}]." }));
|
|
248
|
+
}
|
|
249
|
+
const result = decodeCustomFields(parsed);
|
|
250
|
+
return Result.isFailure(result) ? Effect.fail(new StepFailure({ message: `Custom Fields JSON must be an array of objects with numeric fieldID and value (string or string[]). ${result.failure.message}` })) : Effect.succeed(result.success);
|
|
251
|
+
}
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region src/acuity/index.ts
|
|
254
|
+
const acuityCredentialFields = {
|
|
255
|
+
ACUITY_USER_ID: {
|
|
256
|
+
label: "User ID",
|
|
257
|
+
type: "text",
|
|
258
|
+
placeholder: "12345678",
|
|
259
|
+
helpText: "Your Acuity User ID used as Basic auth username."
|
|
260
|
+
},
|
|
261
|
+
ACUITY_API_KEY: {
|
|
262
|
+
label: "API Key",
|
|
263
|
+
type: "password",
|
|
264
|
+
placeholder: "••••••••",
|
|
265
|
+
helpText: "Your Acuity API key used as Basic auth password."
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
/** What a tri-state Acuity flag offers: leave it alone, or say yes or no. */
|
|
269
|
+
const yesNoSelect = [
|
|
270
|
+
{
|
|
271
|
+
value: "",
|
|
272
|
+
label: "Default"
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
value: "true",
|
|
276
|
+
label: "Yes"
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
value: "false",
|
|
280
|
+
label: "No"
|
|
281
|
+
}
|
|
282
|
+
];
|
|
283
|
+
/**
|
|
284
|
+
* The two flags every mutating Acuity action takes, written once.
|
|
285
|
+
*
|
|
286
|
+
* `satisfies` with the keys spelled out is what keeps each `key` a literal type:
|
|
287
|
+
* an action checks a field's key against its input schema, and a widened `string`
|
|
288
|
+
* names no key at all. All three actions that use this group declare both.
|
|
289
|
+
*/
|
|
290
|
+
const mutationFlagsGroup = {
|
|
291
|
+
type: "group",
|
|
292
|
+
label: "Mutation Flags",
|
|
293
|
+
fields: [{
|
|
294
|
+
key: "admin",
|
|
295
|
+
label: "Run as Admin",
|
|
296
|
+
type: "select",
|
|
297
|
+
defaultValue: "",
|
|
298
|
+
options: yesNoSelect
|
|
299
|
+
}, {
|
|
300
|
+
key: "noEmail",
|
|
301
|
+
label: "Suppress Acuity Emails",
|
|
302
|
+
type: "select",
|
|
303
|
+
defaultValue: "",
|
|
304
|
+
options: yesNoSelect
|
|
305
|
+
}]
|
|
306
|
+
};
|
|
307
|
+
const listAppointmentTypesInput = Schema.Struct({});
|
|
308
|
+
const listAppointmentTypesOutput = Schema.Struct({
|
|
309
|
+
appointmentTypes: Schema.mutable(Schema.Array(appointmentTypeSchema)).annotate({ description: "Array of appointment types" }),
|
|
310
|
+
count: describedNumber("Number of appointment types returned")
|
|
311
|
+
});
|
|
312
|
+
const listAppointmentsInput = Schema.Struct({
|
|
313
|
+
appointmentTypeId: optionalText,
|
|
314
|
+
calendarId: optionalText,
|
|
315
|
+
minDate: optionalText,
|
|
316
|
+
maxDate: optionalText,
|
|
317
|
+
timezone: optionalText,
|
|
318
|
+
email: optionalText,
|
|
319
|
+
phone: optionalText,
|
|
320
|
+
canceled: optionalText,
|
|
321
|
+
showAll: optionalText,
|
|
322
|
+
limit: optionalNumeric,
|
|
323
|
+
page: optionalNumeric
|
|
324
|
+
});
|
|
325
|
+
const listAppointmentsOutput = Schema.Struct({
|
|
326
|
+
appointments: Schema.mutable(Schema.Array(appointmentSchema)).annotate({ description: "Array of appointments" }),
|
|
327
|
+
count: describedNumber("Number of appointments returned")
|
|
328
|
+
});
|
|
329
|
+
const getAppointmentInput = Schema.Struct({
|
|
330
|
+
appointmentId: Schema.String,
|
|
331
|
+
pastFormAnswers: optionalText
|
|
332
|
+
});
|
|
333
|
+
const getAppointmentOutput = Schema.Struct({
|
|
334
|
+
appointment: appointmentSchema.annotate({ description: "The appointment details" }),
|
|
335
|
+
id: describedNumber("Appointment ID"),
|
|
336
|
+
datetime: Schema.String.annotate({ description: "Appointment datetime" })
|
|
337
|
+
});
|
|
338
|
+
const getAvailabilityDatesInput = Schema.Struct({
|
|
339
|
+
month: Schema.String,
|
|
340
|
+
appointmentTypeId: Schema.String,
|
|
341
|
+
calendarId: optionalText,
|
|
342
|
+
timezone: optionalText
|
|
343
|
+
});
|
|
344
|
+
const getAvailabilityDatesOutput = Schema.Struct({
|
|
345
|
+
dates: Schema.mutable(Schema.Array(availabilityDateSchema)).annotate({ description: "Available dates" }),
|
|
346
|
+
count: describedNumber("Number of dates returned")
|
|
347
|
+
});
|
|
348
|
+
const getAvailabilityTimesInput = Schema.Struct({
|
|
349
|
+
date: Schema.String,
|
|
350
|
+
appointmentTypeId: Schema.String,
|
|
351
|
+
calendarId: optionalText,
|
|
352
|
+
timezone: optionalText,
|
|
353
|
+
/** Comma-separated, which is how a single text field carries a list. */
|
|
354
|
+
ignoreAppointmentIds: optionalText
|
|
355
|
+
});
|
|
356
|
+
const getAvailabilityTimesOutput = Schema.Struct({
|
|
357
|
+
slots: Schema.mutable(Schema.Array(availabilityTimeSlotSchema)).annotate({ description: "Available time slots" }),
|
|
358
|
+
count: describedNumber("Number of slots returned")
|
|
359
|
+
});
|
|
360
|
+
const createAppointmentInput = Schema.Struct({
|
|
361
|
+
datetime: Schema.String,
|
|
362
|
+
appointmentTypeId: Schema.String,
|
|
363
|
+
firstName: Schema.String,
|
|
364
|
+
lastName: Schema.String,
|
|
365
|
+
email: Schema.String,
|
|
366
|
+
phone: Schema.String,
|
|
367
|
+
calendarId: optionalText,
|
|
368
|
+
notes: optionalText,
|
|
369
|
+
smsOptIn: optionalText,
|
|
370
|
+
/** JSON the workflow author typed, parsed by the step. */
|
|
371
|
+
customFieldsJson: optionalText,
|
|
372
|
+
admin: optionalText,
|
|
373
|
+
noEmail: optionalText
|
|
374
|
+
});
|
|
375
|
+
const createAppointmentOutput = Schema.Struct({
|
|
376
|
+
appointment: appointmentSchema.annotate({ description: "Created appointment payload" }),
|
|
377
|
+
id: describedNumber("Created appointment ID"),
|
|
378
|
+
datetime: Schema.String.annotate({ description: "Created appointment datetime" })
|
|
379
|
+
});
|
|
380
|
+
const rescheduleAppointmentInput = Schema.Struct({
|
|
381
|
+
appointmentId: Schema.String,
|
|
382
|
+
datetime: Schema.String,
|
|
383
|
+
calendarId: optionalText,
|
|
384
|
+
admin: optionalText,
|
|
385
|
+
noEmail: optionalText
|
|
386
|
+
});
|
|
387
|
+
const rescheduleAppointmentOutput = Schema.Struct({
|
|
388
|
+
appointment: appointmentSchema.annotate({ description: "Rescheduled appointment payload" }),
|
|
389
|
+
id: describedNumber("Appointment ID"),
|
|
390
|
+
datetime: Schema.String.annotate({ description: "New appointment datetime" })
|
|
391
|
+
});
|
|
392
|
+
const cancelAppointmentInput = Schema.Struct({
|
|
393
|
+
appointmentId: Schema.String,
|
|
394
|
+
cancelNote: optionalText,
|
|
395
|
+
noShow: optionalText,
|
|
396
|
+
admin: optionalText,
|
|
397
|
+
noEmail: optionalText
|
|
398
|
+
});
|
|
399
|
+
const cancelAppointmentOutput = Schema.Struct({
|
|
400
|
+
appointment: appointmentSchema.annotate({ description: "Canceled appointment payload" }),
|
|
401
|
+
id: describedNumber("Canceled appointment ID"),
|
|
402
|
+
canceled: Schema.optional(Schema.Boolean.annotate({ description: "Cancellation flag" }))
|
|
403
|
+
});
|
|
404
|
+
function createAcuity(createSdk) {
|
|
405
|
+
return defineIntegration({
|
|
406
|
+
type: "acuity",
|
|
407
|
+
label: "Acuity",
|
|
408
|
+
description: "Manage appointments and availability in Acuity Scheduling",
|
|
409
|
+
credentials: acuityCredentialFields,
|
|
410
|
+
test: async () => {
|
|
411
|
+
const { testAcuity } = await import("./test-DzstoPiR.js");
|
|
412
|
+
return (creds) => testAcuity(creds, createSdk);
|
|
413
|
+
},
|
|
414
|
+
actions: {
|
|
415
|
+
"list-appointment-types": {
|
|
416
|
+
label: "List Appointment Types",
|
|
417
|
+
description: "Fetch appointment types configured in Acuity",
|
|
418
|
+
input: listAppointmentTypesInput,
|
|
419
|
+
output: listAppointmentTypesOutput,
|
|
420
|
+
configFields: [],
|
|
421
|
+
/** The action takes no configuration, so the whole of it is the read. */
|
|
422
|
+
handler: Effect.fn(function* (bag) {
|
|
423
|
+
const client = yield* createAcuityClient(bag, createSdk);
|
|
424
|
+
const appointmentTypes = yield* bag.step.run("list-types", callAcuity("Failed to list appointment types.", () => client.appointments.types()));
|
|
425
|
+
return {
|
|
426
|
+
appointmentTypes,
|
|
427
|
+
count: appointmentTypes.length
|
|
428
|
+
};
|
|
429
|
+
})
|
|
430
|
+
},
|
|
431
|
+
"list-appointments": {
|
|
432
|
+
label: "List Appointments",
|
|
433
|
+
description: "List appointments with optional filters",
|
|
434
|
+
input: listAppointmentsInput,
|
|
435
|
+
output: listAppointmentsOutput,
|
|
436
|
+
configFields: [
|
|
437
|
+
{
|
|
438
|
+
key: "appointmentTypeId",
|
|
439
|
+
label: "Appointment Type ID",
|
|
440
|
+
type: "template-input",
|
|
441
|
+
placeholder: "12345"
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
key: "calendarId",
|
|
445
|
+
label: "Calendar ID",
|
|
446
|
+
type: "template-input",
|
|
447
|
+
placeholder: "67890"
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
key: "minDate",
|
|
451
|
+
label: "Min Date (YYYY-MM-DD)",
|
|
452
|
+
type: "template-input",
|
|
453
|
+
placeholder: "2026-03-01"
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
key: "maxDate",
|
|
457
|
+
label: "Max Date (YYYY-MM-DD)",
|
|
458
|
+
type: "template-input",
|
|
459
|
+
placeholder: "2026-03-31"
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
key: "timezone",
|
|
463
|
+
label: "Timezone",
|
|
464
|
+
type: "template-input",
|
|
465
|
+
placeholder: "America/New_York"
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
type: "group",
|
|
469
|
+
label: "Additional Filters",
|
|
470
|
+
fields: [
|
|
471
|
+
{
|
|
472
|
+
key: "email",
|
|
473
|
+
label: "Client Email",
|
|
474
|
+
type: "template-input",
|
|
475
|
+
placeholder: "person@example.com"
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
key: "phone",
|
|
479
|
+
label: "Client Phone",
|
|
480
|
+
type: "template-input",
|
|
481
|
+
placeholder: "+15551234567"
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
key: "canceled",
|
|
485
|
+
label: "Only Canceled",
|
|
486
|
+
type: "select",
|
|
487
|
+
defaultValue: "",
|
|
488
|
+
options: [
|
|
489
|
+
{
|
|
490
|
+
value: "",
|
|
491
|
+
label: "No filter"
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
value: "true",
|
|
495
|
+
label: "Yes"
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
value: "false",
|
|
499
|
+
label: "No"
|
|
500
|
+
}
|
|
501
|
+
]
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
key: "showAll",
|
|
505
|
+
label: "Include Inactive",
|
|
506
|
+
type: "select",
|
|
507
|
+
defaultValue: "",
|
|
508
|
+
options: yesNoSelect
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
key: "limit",
|
|
512
|
+
label: "Limit",
|
|
513
|
+
type: "number",
|
|
514
|
+
min: 1,
|
|
515
|
+
defaultValue: "50"
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
key: "page",
|
|
519
|
+
label: "Page",
|
|
520
|
+
type: "number",
|
|
521
|
+
min: 1,
|
|
522
|
+
defaultValue: "1"
|
|
523
|
+
}
|
|
524
|
+
]
|
|
525
|
+
}
|
|
526
|
+
],
|
|
527
|
+
/**
|
|
528
|
+
* What this step decides is which filters the config asked for, and each
|
|
529
|
+
* field says for itself what is wrong with it.
|
|
530
|
+
*/
|
|
531
|
+
handler: Effect.fn(function* (bag) {
|
|
532
|
+
const { input } = bag;
|
|
533
|
+
const client = yield* createAcuityClient(bag, createSdk);
|
|
534
|
+
const appointmentTypeID = yield* optionalInteger(input.appointmentTypeId, "Appointment Type ID");
|
|
535
|
+
const calendarID = yield* optionalInteger(input.calendarId, "Calendar ID");
|
|
536
|
+
const limit = yield* optionalInteger(input.limit, "Limit");
|
|
537
|
+
const page = yield* optionalInteger(input.page, "Page");
|
|
538
|
+
const canceled = yield* optionalBoolean(input.canceled, "Only Canceled");
|
|
539
|
+
const showall = yield* optionalBoolean(input.showAll, "Include Inactive");
|
|
540
|
+
const params = {
|
|
541
|
+
appointmentTypeID,
|
|
542
|
+
calendarID,
|
|
543
|
+
minDate: input.minDate,
|
|
544
|
+
maxDate: input.maxDate,
|
|
545
|
+
timezone: input.timezone,
|
|
546
|
+
email: input.email,
|
|
547
|
+
phone: input.phone,
|
|
548
|
+
canceled,
|
|
549
|
+
showall,
|
|
550
|
+
limit,
|
|
551
|
+
page
|
|
552
|
+
};
|
|
553
|
+
const appointments = yield* bag.step.run("list-appointments", callAcuity("Failed to list appointments.", () => client.appointments.list(params)));
|
|
554
|
+
return {
|
|
555
|
+
appointments,
|
|
556
|
+
count: appointments.length
|
|
557
|
+
};
|
|
558
|
+
})
|
|
559
|
+
},
|
|
560
|
+
"get-appointment": {
|
|
561
|
+
label: "Get Appointment",
|
|
562
|
+
description: "Fetch one appointment by ID",
|
|
563
|
+
input: getAppointmentInput,
|
|
564
|
+
output: getAppointmentOutput,
|
|
565
|
+
configFields: [{
|
|
566
|
+
key: "appointmentId",
|
|
567
|
+
label: "Appointment ID",
|
|
568
|
+
type: "template-input",
|
|
569
|
+
placeholder: "123456789",
|
|
570
|
+
required: true
|
|
571
|
+
}, {
|
|
572
|
+
key: "pastFormAnswers",
|
|
573
|
+
label: "Include Past Form Answers",
|
|
574
|
+
type: "select",
|
|
575
|
+
defaultValue: "false",
|
|
576
|
+
options: [{
|
|
577
|
+
value: "false",
|
|
578
|
+
label: "No"
|
|
579
|
+
}, {
|
|
580
|
+
value: "true",
|
|
581
|
+
label: "Yes"
|
|
582
|
+
}]
|
|
583
|
+
}],
|
|
584
|
+
handler: Effect.fn(function* (bag) {
|
|
585
|
+
const { input } = bag;
|
|
586
|
+
const client = yield* createAcuityClient(bag, createSdk);
|
|
587
|
+
const appointmentId = yield* requiredInteger(input.appointmentId, "Appointment ID");
|
|
588
|
+
const pastFormAnswers = yield* optionalBoolean(input.pastFormAnswers, "Include Past Form Answers");
|
|
589
|
+
const appointment = yield* bag.step.run("get-appointment", callAcuity("Failed to fetch appointment.", () => client.appointments.get(appointmentId, { pastFormAnswers })));
|
|
590
|
+
return {
|
|
591
|
+
appointment,
|
|
592
|
+
id: appointment.id,
|
|
593
|
+
datetime: appointment.datetime
|
|
594
|
+
};
|
|
595
|
+
})
|
|
596
|
+
},
|
|
597
|
+
"get-availability-dates": {
|
|
598
|
+
label: "Get Availability Dates",
|
|
599
|
+
description: "List dates that still have available slots",
|
|
600
|
+
input: getAvailabilityDatesInput,
|
|
601
|
+
output: getAvailabilityDatesOutput,
|
|
602
|
+
configFields: [
|
|
603
|
+
{
|
|
604
|
+
key: "month",
|
|
605
|
+
label: "Month (YYYY-MM)",
|
|
606
|
+
type: "template-input",
|
|
607
|
+
placeholder: "2026-03",
|
|
608
|
+
required: true
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
key: "appointmentTypeId",
|
|
612
|
+
label: "Appointment Type ID",
|
|
613
|
+
type: "template-input",
|
|
614
|
+
placeholder: "12345",
|
|
615
|
+
required: true
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
key: "calendarId",
|
|
619
|
+
label: "Calendar ID",
|
|
620
|
+
type: "template-input",
|
|
621
|
+
placeholder: "67890"
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
key: "timezone",
|
|
625
|
+
label: "Timezone",
|
|
626
|
+
type: "template-input",
|
|
627
|
+
placeholder: "America/New_York"
|
|
628
|
+
}
|
|
629
|
+
],
|
|
630
|
+
handler: Effect.fn(function* (bag) {
|
|
631
|
+
const { input } = bag;
|
|
632
|
+
const client = yield* createAcuityClient(bag, createSdk);
|
|
633
|
+
const appointmentTypeID = yield* requiredInteger(input.appointmentTypeId, "Appointment Type ID");
|
|
634
|
+
const calendarID = yield* optionalInteger(input.calendarId, "Calendar ID");
|
|
635
|
+
if (!input.month.trim()) return yield* new StepFailure({ message: "Month is required and must use YYYY-MM format." });
|
|
636
|
+
const params = {
|
|
637
|
+
month: input.month,
|
|
638
|
+
appointmentTypeID,
|
|
639
|
+
calendarID,
|
|
640
|
+
timezone: input.timezone
|
|
641
|
+
};
|
|
642
|
+
const dates = yield* bag.step.run("get-dates", callAcuity("Failed to fetch availability dates.", () => client.availability.dates(params)));
|
|
643
|
+
return {
|
|
644
|
+
dates,
|
|
645
|
+
count: dates.length
|
|
646
|
+
};
|
|
647
|
+
})
|
|
648
|
+
},
|
|
649
|
+
"get-availability-times": {
|
|
650
|
+
label: "Get Availability Times",
|
|
651
|
+
description: "List available time slots for a date",
|
|
652
|
+
input: getAvailabilityTimesInput,
|
|
653
|
+
output: getAvailabilityTimesOutput,
|
|
654
|
+
configFields: [
|
|
655
|
+
{
|
|
656
|
+
key: "date",
|
|
657
|
+
label: "Date (YYYY-MM-DD)",
|
|
658
|
+
type: "template-input",
|
|
659
|
+
placeholder: "2026-03-15",
|
|
660
|
+
required: true
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
key: "appointmentTypeId",
|
|
664
|
+
label: "Appointment Type ID",
|
|
665
|
+
type: "template-input",
|
|
666
|
+
placeholder: "12345",
|
|
667
|
+
required: true
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
key: "calendarId",
|
|
671
|
+
label: "Calendar ID",
|
|
672
|
+
type: "template-input",
|
|
673
|
+
placeholder: "67890"
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
key: "timezone",
|
|
677
|
+
label: "Timezone",
|
|
678
|
+
type: "template-input",
|
|
679
|
+
placeholder: "America/New_York"
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
key: "ignoreAppointmentIds",
|
|
683
|
+
label: "Ignore Appointment IDs (comma separated)",
|
|
684
|
+
type: "template-input",
|
|
685
|
+
placeholder: "111,222"
|
|
686
|
+
}
|
|
687
|
+
],
|
|
688
|
+
handler: Effect.fn(function* (bag) {
|
|
689
|
+
const { input } = bag;
|
|
690
|
+
const client = yield* createAcuityClient(bag, createSdk);
|
|
691
|
+
const appointmentTypeID = yield* requiredInteger(input.appointmentTypeId, "Appointment Type ID");
|
|
692
|
+
const calendarID = yield* optionalInteger(input.calendarId, "Calendar ID");
|
|
693
|
+
const ignoreAppointmentIDs = yield* optionalIntegerList(input.ignoreAppointmentIds, "Ignore Appointment IDs");
|
|
694
|
+
if (!input.date.trim()) return yield* new StepFailure({ message: "Date is required and must use YYYY-MM-DD format." });
|
|
695
|
+
const params = {
|
|
696
|
+
date: input.date,
|
|
697
|
+
appointmentTypeID,
|
|
698
|
+
calendarID,
|
|
699
|
+
timezone: input.timezone,
|
|
700
|
+
ignoreAppointmentIDs
|
|
701
|
+
};
|
|
702
|
+
const slots = yield* bag.step.run("get-times", callAcuity("Failed to fetch availability times.", () => client.availability.times(params)));
|
|
703
|
+
return {
|
|
704
|
+
slots,
|
|
705
|
+
count: slots.length
|
|
706
|
+
};
|
|
707
|
+
})
|
|
708
|
+
},
|
|
709
|
+
"create-appointment": {
|
|
710
|
+
label: "Create Appointment",
|
|
711
|
+
description: "Book a new appointment in Acuity",
|
|
712
|
+
input: createAppointmentInput,
|
|
713
|
+
output: createAppointmentOutput,
|
|
714
|
+
configFields: [
|
|
715
|
+
{
|
|
716
|
+
key: "datetime",
|
|
717
|
+
label: "Datetime (ISO 8601)",
|
|
718
|
+
type: "template-input",
|
|
719
|
+
placeholder: "2026-03-15T15:00:00-04:00",
|
|
720
|
+
required: true
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
key: "appointmentTypeId",
|
|
724
|
+
label: "Appointment Type ID",
|
|
725
|
+
type: "template-input",
|
|
726
|
+
placeholder: "12345",
|
|
727
|
+
required: true
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
key: "firstName",
|
|
731
|
+
label: "First Name",
|
|
732
|
+
type: "template-input",
|
|
733
|
+
placeholder: "Alice",
|
|
734
|
+
required: true
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
key: "lastName",
|
|
738
|
+
label: "Last Name",
|
|
739
|
+
type: "template-input",
|
|
740
|
+
placeholder: "Johnson",
|
|
741
|
+
required: true
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
key: "email",
|
|
745
|
+
label: "Email",
|
|
746
|
+
type: "template-input",
|
|
747
|
+
placeholder: "alice@example.com",
|
|
748
|
+
required: true
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
key: "phone",
|
|
752
|
+
label: "Phone",
|
|
753
|
+
type: "template-input",
|
|
754
|
+
placeholder: "+15551234567",
|
|
755
|
+
required: true
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
type: "group",
|
|
759
|
+
label: "Optional Appointment Fields",
|
|
760
|
+
fields: [
|
|
761
|
+
{
|
|
762
|
+
key: "calendarId",
|
|
763
|
+
label: "Calendar ID",
|
|
764
|
+
type: "template-input",
|
|
765
|
+
placeholder: "67890"
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
key: "notes",
|
|
769
|
+
label: "Notes",
|
|
770
|
+
type: "template-textarea",
|
|
771
|
+
placeholder: "Optional internal notes",
|
|
772
|
+
rows: 3
|
|
773
|
+
},
|
|
774
|
+
{
|
|
775
|
+
key: "smsOptIn",
|
|
776
|
+
label: "SMS Opt-In",
|
|
777
|
+
type: "select",
|
|
778
|
+
defaultValue: "",
|
|
779
|
+
options: yesNoSelect
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
key: "customFieldsJson",
|
|
783
|
+
label: "Custom Fields JSON",
|
|
784
|
+
type: "template-textarea",
|
|
785
|
+
rows: 5,
|
|
786
|
+
placeholder: "[{\"fieldID\":1234,\"value\":\"Some answer\"},{\"fieldID\":5678,\"value\":[\"A\",\"B\"]}]"
|
|
787
|
+
}
|
|
788
|
+
]
|
|
789
|
+
},
|
|
790
|
+
mutationFlagsGroup
|
|
791
|
+
],
|
|
792
|
+
/**
|
|
793
|
+
* What this step decides is what a booking looks like, and each field says
|
|
794
|
+
* for itself what is wrong with it.
|
|
795
|
+
*/
|
|
796
|
+
handler: Effect.fn(function* (bag) {
|
|
797
|
+
const { input } = bag;
|
|
798
|
+
const client = yield* createAcuityClient(bag, createSdk);
|
|
799
|
+
const appointmentTypeID = yield* requiredInteger(input.appointmentTypeId, "Appointment Type ID");
|
|
800
|
+
const calendarID = yield* optionalInteger(input.calendarId, "Calendar ID");
|
|
801
|
+
const smsOptIn = yield* optionalBoolean(input.smsOptIn, "SMS Opt-In");
|
|
802
|
+
const admin = yield* optionalBoolean(input.admin, "Run as Admin");
|
|
803
|
+
const noEmail = yield* optionalBoolean(input.noEmail, "Suppress Acuity Emails");
|
|
804
|
+
const fields = yield* optionalCustomFields(input.customFieldsJson);
|
|
805
|
+
if (!input.datetime.trim()) return yield* new StepFailure({ message: "Datetime is required (ISO 8601 format)." });
|
|
806
|
+
if (!(input.firstName.trim() && input.lastName.trim())) return yield* new StepFailure({ message: "First Name and Last Name are required." });
|
|
807
|
+
if (!(input.email.trim() && input.phone.trim())) return yield* new StepFailure({ message: "Email and Phone are required." });
|
|
808
|
+
const payload = {
|
|
809
|
+
datetime: input.datetime,
|
|
810
|
+
appointmentTypeID,
|
|
811
|
+
firstName: input.firstName,
|
|
812
|
+
lastName: input.lastName,
|
|
813
|
+
email: input.email,
|
|
814
|
+
phone: input.phone,
|
|
815
|
+
calendarID,
|
|
816
|
+
notes: input.notes,
|
|
817
|
+
smsOptIn,
|
|
818
|
+
fields
|
|
819
|
+
};
|
|
820
|
+
const appointment = yield* bag.step.run("create-appointment", callAcuity("Failed to create appointment.", () => client.appointments.create(payload, {
|
|
821
|
+
admin,
|
|
822
|
+
noEmail
|
|
823
|
+
})));
|
|
824
|
+
return {
|
|
825
|
+
appointment,
|
|
826
|
+
id: appointment.id,
|
|
827
|
+
datetime: appointment.datetime
|
|
828
|
+
};
|
|
829
|
+
})
|
|
830
|
+
},
|
|
831
|
+
"reschedule-appointment": {
|
|
832
|
+
label: "Reschedule Appointment",
|
|
833
|
+
description: "Move an appointment to a new datetime",
|
|
834
|
+
input: rescheduleAppointmentInput,
|
|
835
|
+
output: rescheduleAppointmentOutput,
|
|
836
|
+
configFields: [
|
|
837
|
+
{
|
|
838
|
+
key: "appointmentId",
|
|
839
|
+
label: "Appointment ID",
|
|
840
|
+
type: "template-input",
|
|
841
|
+
placeholder: "123456789",
|
|
842
|
+
required: true
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
key: "datetime",
|
|
846
|
+
label: "New Datetime (ISO 8601)",
|
|
847
|
+
type: "template-input",
|
|
848
|
+
placeholder: "2026-03-16T10:00:00-04:00",
|
|
849
|
+
required: true
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
key: "calendarId",
|
|
853
|
+
label: "Calendar ID",
|
|
854
|
+
type: "template-input",
|
|
855
|
+
placeholder: "67890"
|
|
856
|
+
},
|
|
857
|
+
mutationFlagsGroup
|
|
858
|
+
],
|
|
859
|
+
handler: Effect.fn(function* (bag) {
|
|
860
|
+
const { input } = bag;
|
|
861
|
+
const client = yield* createAcuityClient(bag, createSdk);
|
|
862
|
+
const appointmentId = yield* requiredInteger(input.appointmentId, "Appointment ID");
|
|
863
|
+
if (!input.datetime.trim()) return yield* new StepFailure({ message: "New Datetime is required (ISO 8601 format)." });
|
|
864
|
+
const calendarID = yield* optionalInteger(input.calendarId, "Calendar ID");
|
|
865
|
+
const admin = yield* optionalBoolean(input.admin, "Run as Admin");
|
|
866
|
+
const noEmail = yield* optionalBoolean(input.noEmail, "Suppress Acuity Emails");
|
|
867
|
+
const appointment = yield* bag.step.run("reschedule-appointment", callAcuity("Failed to reschedule appointment.", () => client.appointments.reschedule(appointmentId, {
|
|
868
|
+
datetime: input.datetime,
|
|
869
|
+
calendarID
|
|
870
|
+
}, {
|
|
871
|
+
admin,
|
|
872
|
+
noEmail
|
|
873
|
+
})));
|
|
874
|
+
return {
|
|
875
|
+
appointment,
|
|
876
|
+
id: appointment.id,
|
|
877
|
+
datetime: appointment.datetime
|
|
878
|
+
};
|
|
879
|
+
})
|
|
880
|
+
},
|
|
881
|
+
"cancel-appointment": {
|
|
882
|
+
label: "Cancel Appointment",
|
|
883
|
+
description: "Cancel an appointment in Acuity",
|
|
884
|
+
input: cancelAppointmentInput,
|
|
885
|
+
output: cancelAppointmentOutput,
|
|
886
|
+
configFields: [
|
|
887
|
+
{
|
|
888
|
+
key: "appointmentId",
|
|
889
|
+
label: "Appointment ID",
|
|
890
|
+
type: "template-input",
|
|
891
|
+
placeholder: "123456789",
|
|
892
|
+
required: true
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
key: "cancelNote",
|
|
896
|
+
label: "Cancel Note",
|
|
897
|
+
type: "template-textarea",
|
|
898
|
+
rows: 3,
|
|
899
|
+
placeholder: "Optional cancellation reason"
|
|
900
|
+
},
|
|
901
|
+
{
|
|
902
|
+
key: "noShow",
|
|
903
|
+
label: "Mark as No-Show",
|
|
904
|
+
type: "select",
|
|
905
|
+
defaultValue: "",
|
|
906
|
+
options: yesNoSelect
|
|
907
|
+
},
|
|
908
|
+
mutationFlagsGroup
|
|
909
|
+
],
|
|
910
|
+
handler: Effect.fn(function* (bag) {
|
|
911
|
+
const { input } = bag;
|
|
912
|
+
const client = yield* createAcuityClient(bag, createSdk);
|
|
913
|
+
const appointmentId = yield* requiredInteger(input.appointmentId, "Appointment ID");
|
|
914
|
+
const noShow = yield* optionalBoolean(input.noShow, "Mark as No-Show");
|
|
915
|
+
const admin = yield* optionalBoolean(input.admin, "Run as Admin");
|
|
916
|
+
const noEmail = yield* optionalBoolean(input.noEmail, "Suppress Acuity Emails");
|
|
917
|
+
const appointment = yield* bag.step.run("cancel-appointment", callAcuity("Failed to cancel appointment.", () => client.appointments.cancel(appointmentId, {
|
|
918
|
+
cancelNote: input.cancelNote,
|
|
919
|
+
noShow
|
|
920
|
+
}, {
|
|
921
|
+
admin,
|
|
922
|
+
noEmail
|
|
923
|
+
})));
|
|
924
|
+
return {
|
|
925
|
+
appointment,
|
|
926
|
+
id: appointment.id,
|
|
927
|
+
canceled: appointment.canceled
|
|
928
|
+
};
|
|
929
|
+
})
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
const acuity = createAcuity(createAcuitySdk);
|
|
935
|
+
//#endregion
|
|
936
|
+
//#region src/clerk/metadata.ts
|
|
937
|
+
/**
|
|
938
|
+
* The JSON a workflow author types into a metadata field, as Clerk takes it.
|
|
939
|
+
*
|
|
940
|
+
* Create User and Update User both offer the two metadata boxes, so the reading
|
|
941
|
+
* of them is written once here rather than twice beside the steps.
|
|
942
|
+
*/
|
|
943
|
+
const readClerkMetadata = readAs(Schema.Record(Schema.String, Schema.Unknown));
|
|
944
|
+
/**
|
|
945
|
+
* A metadata box's contents, or nothing when the box was left empty.
|
|
946
|
+
*
|
|
947
|
+
* Text that is not a JSON object fails the step rather than being dropped: the
|
|
948
|
+
* author meant to attach something, and a user created without it is a silent
|
|
949
|
+
* wrong answer.
|
|
950
|
+
*/
|
|
951
|
+
function parseClerkMetadata(value, fieldName) {
|
|
952
|
+
if (!value) return Effect.succeed(void 0);
|
|
953
|
+
let parsed;
|
|
954
|
+
try {
|
|
955
|
+
parsed = JSON.parse(value);
|
|
956
|
+
} catch {
|
|
957
|
+
return Effect.fail(new StepFailure({ message: `Invalid JSON format for ${fieldName}` }));
|
|
958
|
+
}
|
|
959
|
+
const metadata = readClerkMetadata(parsed);
|
|
960
|
+
return metadata ? Effect.succeed(metadata) : Effect.fail(new StepFailure({ message: `Invalid JSON format for ${fieldName}` }));
|
|
961
|
+
}
|
|
962
|
+
//#endregion
|
|
963
|
+
//#region src/clerk/types.ts
|
|
964
|
+
/**
|
|
965
|
+
* Clerk's user resource as the three user-returning steps answer with it: the
|
|
966
|
+
* primary address picked out of the address list, and the metadata objects left
|
|
967
|
+
* behind, since a downstream node has no shape to address them by.
|
|
968
|
+
*/
|
|
969
|
+
function toClerkUserData(apiUser) {
|
|
970
|
+
const primaryEmail = apiUser.email_addresses.find((e) => e.id === apiUser.primary_email_address_id);
|
|
971
|
+
return {
|
|
972
|
+
id: apiUser.id,
|
|
973
|
+
firstName: apiUser.first_name,
|
|
974
|
+
lastName: apiUser.last_name,
|
|
975
|
+
primaryEmailAddress: primaryEmail?.email_address ?? null,
|
|
976
|
+
createdAt: apiUser.created_at,
|
|
977
|
+
updatedAt: apiUser.updated_at
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
//#endregion
|
|
981
|
+
//#region src/clerk/index.ts
|
|
982
|
+
/**
|
|
983
|
+
* The Clerk integration: its credentials, its four actions, and what each takes
|
|
984
|
+
* and gives back.
|
|
985
|
+
*
|
|
986
|
+
* One file, because only the server imports it. The editor gets this plugin's
|
|
987
|
+
* metadata as JSON over `/api/extensions`, so nothing here reaches a browser
|
|
988
|
+
* bundle and `@clerk/backend`, which `client.ts` pulls in, costs the browser
|
|
989
|
+
* nothing. The icon is the exception, since a React component cannot be
|
|
990
|
+
* serialized: it stays in `ui.ts`, which only the browser imports.
|
|
991
|
+
*/
|
|
992
|
+
const clerkCredentialFields = { CLERK_SECRET_KEY: {
|
|
993
|
+
label: "Secret Key",
|
|
994
|
+
type: "password",
|
|
995
|
+
placeholder: "sk_live_... or sk_test_...",
|
|
996
|
+
helpText: "Get your secret key from ",
|
|
997
|
+
helpLink: {
|
|
998
|
+
text: "Clerk Dashboard",
|
|
999
|
+
url: "https://dashboard.clerk.com"
|
|
1000
|
+
}
|
|
1001
|
+
} };
|
|
1002
|
+
/**
|
|
1003
|
+
* The user three of the four actions answer with, flattened out of Clerk's
|
|
1004
|
+
* resource: the primary address is picked out of the address list, and the
|
|
1005
|
+
* metadata objects stay behind, since a downstream node has no shape to address
|
|
1006
|
+
* them by.
|
|
1007
|
+
*
|
|
1008
|
+
* The three Clerk may have nothing to give are `optionalKey(NullOr(...))`, which is
|
|
1009
|
+
* the one spelling that survives an absent key and an explicit null alike. The
|
|
1010
|
+
* condition builder reads `nullable` off it and offers the null checks, which is the
|
|
1011
|
+
* right question about a missing name.
|
|
1012
|
+
*/
|
|
1013
|
+
const clerkUserOutput = {
|
|
1014
|
+
id: Schema.String.annotate({ description: "User ID" }),
|
|
1015
|
+
firstName: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "First name" }))),
|
|
1016
|
+
lastName: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Last name" }))),
|
|
1017
|
+
primaryEmailAddress: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Primary email address" }))),
|
|
1018
|
+
createdAt: Schema.Number.annotate({ description: "When the user was created, epoch milliseconds" }).check(Schema.isFinite()),
|
|
1019
|
+
updatedAt: Schema.Number.annotate({ description: "When the user was last updated, epoch milliseconds" }).check(Schema.isFinite())
|
|
1020
|
+
};
|
|
1021
|
+
const getUserInput = Schema.Struct({ userId: Schema.String });
|
|
1022
|
+
const getUserOutput = Schema.Struct(clerkUserOutput);
|
|
1023
|
+
/** `optionalKey` for a field a builder may leave blank: it reaches a step absent. */
|
|
1024
|
+
const createUserInput = Schema.Struct({
|
|
1025
|
+
emailAddress: Schema.String,
|
|
1026
|
+
firstName: Schema.optionalKey(Schema.String),
|
|
1027
|
+
lastName: Schema.optionalKey(Schema.String),
|
|
1028
|
+
password: Schema.optionalKey(Schema.String),
|
|
1029
|
+
/** JSON the workflow author typed, parsed by the step. */
|
|
1030
|
+
publicMetadata: Schema.optionalKey(Schema.String),
|
|
1031
|
+
privateMetadata: Schema.optionalKey(Schema.String)
|
|
1032
|
+
});
|
|
1033
|
+
const createUserOutput = Schema.Struct(clerkUserOutput);
|
|
1034
|
+
const updateUserInput = Schema.Struct({
|
|
1035
|
+
userId: Schema.String,
|
|
1036
|
+
firstName: Schema.optionalKey(Schema.String),
|
|
1037
|
+
lastName: Schema.optionalKey(Schema.String),
|
|
1038
|
+
publicMetadata: Schema.optionalKey(Schema.String),
|
|
1039
|
+
privateMetadata: Schema.optionalKey(Schema.String)
|
|
1040
|
+
});
|
|
1041
|
+
const updateUserOutput = Schema.Struct(clerkUserOutput);
|
|
1042
|
+
const deleteUserInput = Schema.Struct({ userId: Schema.String });
|
|
1043
|
+
const deleteUserOutput = Schema.Struct({ deleted: Schema.Boolean.annotate({ description: "Deletion success" }) });
|
|
1044
|
+
const clerk = defineIntegration({
|
|
1045
|
+
type: "clerk",
|
|
1046
|
+
label: "Clerk",
|
|
1047
|
+
description: "User authentication and management",
|
|
1048
|
+
credentials: clerkCredentialFields,
|
|
1049
|
+
test: async () => (await import("./test-Dsrib2EJ.js")).testClerk,
|
|
1050
|
+
actions: {
|
|
1051
|
+
"get-user": {
|
|
1052
|
+
label: "Get User",
|
|
1053
|
+
description: "Fetch a user by ID from Clerk",
|
|
1054
|
+
input: getUserInput,
|
|
1055
|
+
output: getUserOutput,
|
|
1056
|
+
configFields: [{
|
|
1057
|
+
key: "userId",
|
|
1058
|
+
label: "User ID",
|
|
1059
|
+
type: "template-input",
|
|
1060
|
+
placeholder: "user_... or {{NodeName.userId}}",
|
|
1061
|
+
example: "user_2abc123",
|
|
1062
|
+
required: true
|
|
1063
|
+
}],
|
|
1064
|
+
handler: Effect.fn(function* (bag) {
|
|
1065
|
+
const { input } = bag;
|
|
1066
|
+
const secretKey = (yield* bag.credentials).CLERK_SECRET_KEY;
|
|
1067
|
+
if (!secretKey) return yield* new StepFailure({ message: "CLERK_SECRET_KEY is not configured. Please add it in Project Integrations." });
|
|
1068
|
+
if (!input.userId) return yield* new StepFailure({ message: "User ID is required." });
|
|
1069
|
+
const client = createClerkBackendClient(secretKey);
|
|
1070
|
+
return yield* bag.step.run("get-user", Effect.tryPromise({
|
|
1071
|
+
try: () => client.users.getUser(input.userId),
|
|
1072
|
+
catch: (error) => new StepFailure({ message: `Failed to get user: ${getClerkApiErrorMessage(error)}` })
|
|
1073
|
+
}).pipe(Effect.map((user) => toClerkUserData(toClerkApiUser(user)))));
|
|
1074
|
+
})
|
|
1075
|
+
},
|
|
1076
|
+
"create-user": {
|
|
1077
|
+
label: "Create User",
|
|
1078
|
+
description: "Create a new user in Clerk",
|
|
1079
|
+
input: createUserInput,
|
|
1080
|
+
output: createUserOutput,
|
|
1081
|
+
configFields: [
|
|
1082
|
+
{
|
|
1083
|
+
key: "emailAddress",
|
|
1084
|
+
label: "Email Address",
|
|
1085
|
+
type: "template-input",
|
|
1086
|
+
placeholder: "user@example.com or {{NodeName.email}}",
|
|
1087
|
+
example: "user@example.com",
|
|
1088
|
+
required: true
|
|
1089
|
+
},
|
|
1090
|
+
{
|
|
1091
|
+
key: "firstName",
|
|
1092
|
+
label: "First Name",
|
|
1093
|
+
type: "template-input",
|
|
1094
|
+
placeholder: "John or {{NodeName.firstName}}",
|
|
1095
|
+
example: "John"
|
|
1096
|
+
},
|
|
1097
|
+
{
|
|
1098
|
+
key: "lastName",
|
|
1099
|
+
label: "Last Name",
|
|
1100
|
+
type: "template-input",
|
|
1101
|
+
placeholder: "Doe or {{NodeName.lastName}}",
|
|
1102
|
+
example: "Doe"
|
|
1103
|
+
},
|
|
1104
|
+
{
|
|
1105
|
+
key: "password",
|
|
1106
|
+
label: "Password",
|
|
1107
|
+
type: "template-input",
|
|
1108
|
+
placeholder: "Password (min 8 chars) or leave empty",
|
|
1109
|
+
example: "securepassword123"
|
|
1110
|
+
},
|
|
1111
|
+
{
|
|
1112
|
+
label: "Metadata",
|
|
1113
|
+
type: "group",
|
|
1114
|
+
defaultExpanded: false,
|
|
1115
|
+
fields: [{
|
|
1116
|
+
key: "publicMetadata",
|
|
1117
|
+
label: "Public Metadata (JSON)",
|
|
1118
|
+
type: "template-textarea",
|
|
1119
|
+
placeholder: "{\"role\": \"admin\"} or {{NodeName.metadata}}",
|
|
1120
|
+
rows: 3
|
|
1121
|
+
}, {
|
|
1122
|
+
key: "privateMetadata",
|
|
1123
|
+
label: "Private Metadata (JSON)",
|
|
1124
|
+
type: "template-textarea",
|
|
1125
|
+
placeholder: "{\"internal_id\": \"123\"}",
|
|
1126
|
+
rows: 3
|
|
1127
|
+
}]
|
|
1128
|
+
}
|
|
1129
|
+
],
|
|
1130
|
+
handler: Effect.fn(function* (bag) {
|
|
1131
|
+
const { input } = bag;
|
|
1132
|
+
const secretKey = (yield* bag.credentials).CLERK_SECRET_KEY;
|
|
1133
|
+
if (!secretKey) return yield* new StepFailure({ message: "CLERK_SECRET_KEY is not configured. Please add it in Project Integrations." });
|
|
1134
|
+
if (!input.emailAddress) return yield* new StepFailure({ message: "Email address is required." });
|
|
1135
|
+
const publicMetadata = yield* parseClerkMetadata(input.publicMetadata, "publicMetadata");
|
|
1136
|
+
const privateMetadata = yield* parseClerkMetadata(input.privateMetadata, "privateMetadata");
|
|
1137
|
+
const client = createClerkBackendClient(secretKey);
|
|
1138
|
+
const createPayload = omitBy({
|
|
1139
|
+
emailAddress: [input.emailAddress],
|
|
1140
|
+
firstName: input.firstName,
|
|
1141
|
+
lastName: input.lastName,
|
|
1142
|
+
password: input.password,
|
|
1143
|
+
publicMetadata,
|
|
1144
|
+
privateMetadata
|
|
1145
|
+
}, isNil);
|
|
1146
|
+
return yield* bag.step.run("create-user", Effect.tryPromise({
|
|
1147
|
+
try: () => client.users.createUser(createPayload),
|
|
1148
|
+
catch: (error) => new StepFailure({ message: `Failed to create user: ${getClerkApiErrorMessage(error)}` })
|
|
1149
|
+
}).pipe(Effect.map((user) => toClerkUserData(toClerkApiUser(user)))));
|
|
1150
|
+
})
|
|
1151
|
+
},
|
|
1152
|
+
"update-user": {
|
|
1153
|
+
label: "Update User",
|
|
1154
|
+
description: "Update an existing user in Clerk",
|
|
1155
|
+
input: updateUserInput,
|
|
1156
|
+
output: updateUserOutput,
|
|
1157
|
+
configFields: [
|
|
1158
|
+
{
|
|
1159
|
+
key: "userId",
|
|
1160
|
+
label: "User ID",
|
|
1161
|
+
type: "template-input",
|
|
1162
|
+
placeholder: "user_... or {{NodeName.user.id}}",
|
|
1163
|
+
example: "user_2abc123",
|
|
1164
|
+
required: true
|
|
1165
|
+
},
|
|
1166
|
+
{
|
|
1167
|
+
key: "firstName",
|
|
1168
|
+
label: "First Name",
|
|
1169
|
+
type: "template-input",
|
|
1170
|
+
placeholder: "Jane or {{NodeName.firstName}}"
|
|
1171
|
+
},
|
|
1172
|
+
{
|
|
1173
|
+
key: "lastName",
|
|
1174
|
+
label: "Last Name",
|
|
1175
|
+
type: "template-input",
|
|
1176
|
+
placeholder: "Doe or {{NodeName.lastName}}"
|
|
1177
|
+
},
|
|
1178
|
+
{
|
|
1179
|
+
label: "Metadata",
|
|
1180
|
+
type: "group",
|
|
1181
|
+
defaultExpanded: false,
|
|
1182
|
+
fields: [{
|
|
1183
|
+
key: "publicMetadata",
|
|
1184
|
+
label: "Public Metadata (JSON)",
|
|
1185
|
+
type: "template-textarea",
|
|
1186
|
+
placeholder: "{\"role\": \"admin\"} or {{NodeName.metadata}}",
|
|
1187
|
+
rows: 3
|
|
1188
|
+
}, {
|
|
1189
|
+
key: "privateMetadata",
|
|
1190
|
+
label: "Private Metadata (JSON)",
|
|
1191
|
+
type: "template-textarea",
|
|
1192
|
+
placeholder: "{\"internal_id\": \"123\"}",
|
|
1193
|
+
rows: 3
|
|
1194
|
+
}]
|
|
1195
|
+
}
|
|
1196
|
+
],
|
|
1197
|
+
handler: Effect.fn(function* (bag) {
|
|
1198
|
+
const { input } = bag;
|
|
1199
|
+
const secretKey = (yield* bag.credentials).CLERK_SECRET_KEY;
|
|
1200
|
+
if (!secretKey) return yield* new StepFailure({ message: "CLERK_SECRET_KEY is not configured. Please add it in Project Integrations." });
|
|
1201
|
+
if (!input.userId) return yield* new StepFailure({ message: "User ID is required." });
|
|
1202
|
+
const publicMetadata = yield* parseClerkMetadata(input.publicMetadata, "publicMetadata");
|
|
1203
|
+
const privateMetadata = yield* parseClerkMetadata(input.privateMetadata, "privateMetadata");
|
|
1204
|
+
const client = createClerkBackendClient(secretKey);
|
|
1205
|
+
const updatePayload = omitBy({
|
|
1206
|
+
firstName: input.firstName,
|
|
1207
|
+
lastName: input.lastName,
|
|
1208
|
+
publicMetadata,
|
|
1209
|
+
privateMetadata
|
|
1210
|
+
}, isNil);
|
|
1211
|
+
return yield* bag.step.run("update-user", Effect.tryPromise({
|
|
1212
|
+
try: () => client.users.updateUser(input.userId, updatePayload),
|
|
1213
|
+
catch: (error) => new StepFailure({ message: `Failed to update user: ${getClerkApiErrorMessage(error)}` })
|
|
1214
|
+
}).pipe(Effect.map((user) => toClerkUserData(toClerkApiUser(user)))));
|
|
1215
|
+
})
|
|
1216
|
+
},
|
|
1217
|
+
"delete-user": {
|
|
1218
|
+
label: "Delete User",
|
|
1219
|
+
description: "Delete a user from Clerk",
|
|
1220
|
+
input: deleteUserInput,
|
|
1221
|
+
output: deleteUserOutput,
|
|
1222
|
+
configFields: [{
|
|
1223
|
+
key: "userId",
|
|
1224
|
+
label: "User ID",
|
|
1225
|
+
type: "template-input",
|
|
1226
|
+
placeholder: "user_... or {{NodeName.user.id}}",
|
|
1227
|
+
example: "user_2abc123",
|
|
1228
|
+
required: true
|
|
1229
|
+
}],
|
|
1230
|
+
handler: Effect.fn(function* (bag) {
|
|
1231
|
+
const { input } = bag;
|
|
1232
|
+
const secretKey = (yield* bag.credentials).CLERK_SECRET_KEY;
|
|
1233
|
+
if (!secretKey) return yield* new StepFailure({ message: "CLERK_SECRET_KEY is not configured. Please add it in Project Integrations." });
|
|
1234
|
+
if (!input.userId) return yield* new StepFailure({ message: "User ID is required." });
|
|
1235
|
+
const client = createClerkBackendClient(secretKey);
|
|
1236
|
+
yield* bag.step.run("delete-user", Effect.tryPromise({
|
|
1237
|
+
try: () => client.users.deleteUser(input.userId),
|
|
1238
|
+
catch: (error) => new StepFailure({ message: `Failed to delete user: ${getClerkApiErrorMessage(error)}` })
|
|
1239
|
+
}).pipe(Effect.asVoid));
|
|
1240
|
+
return { deleted: true };
|
|
1241
|
+
})
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
//#endregion
|
|
1246
|
+
//#region src/linear/client.ts
|
|
1247
|
+
/** Production Linear SDK client construction from an API key. */
|
|
1248
|
+
function createLinearClient(apiKey) {
|
|
1249
|
+
return new LinearClient({ apiKey });
|
|
1250
|
+
}
|
|
1251
|
+
//#endregion
|
|
1252
|
+
//#region src/linear/index.ts
|
|
1253
|
+
const linearCredentialFields = {
|
|
1254
|
+
LINEAR_API_KEY: {
|
|
1255
|
+
label: "API Key",
|
|
1256
|
+
type: "password",
|
|
1257
|
+
placeholder: "lin_api_...",
|
|
1258
|
+
helpText: "Get your API key from ",
|
|
1259
|
+
helpLink: {
|
|
1260
|
+
text: "linear.app",
|
|
1261
|
+
url: "https://linear.app/settings/account/security/api-keys/new"
|
|
1262
|
+
}
|
|
1263
|
+
},
|
|
1264
|
+
LINEAR_TEAM_ID: {
|
|
1265
|
+
label: "Team ID (Optional)",
|
|
1266
|
+
type: "text",
|
|
1267
|
+
placeholder: "Will use first team if not specified",
|
|
1268
|
+
helpText: "The team ID to create issues in. Leave blank to use your first team."
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
/**
|
|
1272
|
+
* The Create Ticket config, as the step reads it.
|
|
1273
|
+
*
|
|
1274
|
+
* `optionalKey` for a field a builder may leave blank: the engine resolves a
|
|
1275
|
+
* node's templates into the keys the node holds and drops an empty one, so a
|
|
1276
|
+
* blank field reaches a step as an absent key.
|
|
1277
|
+
*/
|
|
1278
|
+
const createTicketInput = Schema.Struct({
|
|
1279
|
+
ticketTitle: Schema.String,
|
|
1280
|
+
ticketDescription: Schema.optionalKey(Schema.String)
|
|
1281
|
+
});
|
|
1282
|
+
const createTicketOutput = Schema.Struct({
|
|
1283
|
+
id: Schema.String.annotate({ description: "Ticket ID" }),
|
|
1284
|
+
url: Schema.String.annotate({ description: "Ticket URL" }),
|
|
1285
|
+
title: Schema.String.annotate({ description: "Ticket title" })
|
|
1286
|
+
});
|
|
1287
|
+
const findIssuesInput = Schema.Struct({
|
|
1288
|
+
linearAssigneeId: Schema.optionalKey(Schema.String),
|
|
1289
|
+
linearTeamId: Schema.optionalKey(Schema.String),
|
|
1290
|
+
linearStatus: Schema.optionalKey(Schema.Literals([
|
|
1291
|
+
"any",
|
|
1292
|
+
"backlog",
|
|
1293
|
+
"todo",
|
|
1294
|
+
"in_progress",
|
|
1295
|
+
"done",
|
|
1296
|
+
"canceled"
|
|
1297
|
+
])),
|
|
1298
|
+
linearLabel: Schema.optionalKey(Schema.String)
|
|
1299
|
+
});
|
|
1300
|
+
/**
|
|
1301
|
+
* Linear state names are workspace-owned and may be renamed. Its state types
|
|
1302
|
+
* are the stable vocabulary behind the labels the select presents.
|
|
1303
|
+
*/
|
|
1304
|
+
const linearStateType = {
|
|
1305
|
+
backlog: "backlog",
|
|
1306
|
+
todo: "unstarted",
|
|
1307
|
+
in_progress: "started",
|
|
1308
|
+
done: "completed",
|
|
1309
|
+
canceled: "canceled"
|
|
1310
|
+
};
|
|
1311
|
+
/**
|
|
1312
|
+
* One issue as the step reports it, flattened out of Linear's GraphQL objects.
|
|
1313
|
+
*
|
|
1314
|
+
* The annotations matter twice here: the picker lists `issues[0].title` beside
|
|
1315
|
+
* `issues`, so a field inside the list needs to say what it is exactly as much
|
|
1316
|
+
* as a field beside it does.
|
|
1317
|
+
*/
|
|
1318
|
+
const linearIssueSchema = Schema.Struct({
|
|
1319
|
+
id: Schema.String.annotate({ description: "Issue ID" }),
|
|
1320
|
+
title: Schema.String.annotate({ description: "Issue title" }),
|
|
1321
|
+
url: Schema.String.annotate({ description: "Issue URL" }),
|
|
1322
|
+
state: Schema.String.annotate({ description: "Workflow state name" }),
|
|
1323
|
+
priority: Schema.Number.annotate({ description: "Priority, 0 (none) through 4 (low)" }).check(Schema.isFinite()),
|
|
1324
|
+
assigneeId: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Assigned user ID" })))
|
|
1325
|
+
});
|
|
1326
|
+
const findIssuesOutput = Schema.Struct({
|
|
1327
|
+
issues: Schema.Array(linearIssueSchema).annotate({ description: "Array of issues found" }),
|
|
1328
|
+
count: Schema.Number.annotate({ description: "Number of issues" }).check(Schema.isFinite())
|
|
1329
|
+
});
|
|
1330
|
+
/**
|
|
1331
|
+
* Everything the Linear SDK does is a Promise that throws, so every call goes
|
|
1332
|
+
* through here: the throw becomes the `StepFailure` the run log shows, worded
|
|
1333
|
+
* the way the calling action words its failures.
|
|
1334
|
+
*/
|
|
1335
|
+
function callLinear(describe, call) {
|
|
1336
|
+
return Effect.tryPromise({
|
|
1337
|
+
try: call,
|
|
1338
|
+
catch: (error) => new StepFailure({ message: `${describe}: ${describeLinearFailure(error)}` })
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* The team a ticket goes to when the integration names none: Linear's first,
|
|
1343
|
+
* which is the whole workspace for the single-team case this covers.
|
|
1344
|
+
*/
|
|
1345
|
+
function firstTeamId(client) {
|
|
1346
|
+
return Effect.gen(function* () {
|
|
1347
|
+
const firstTeam = (yield* callLinear("Failed to create ticket", () => client.teams({ first: 1 }))).nodes[0];
|
|
1348
|
+
return firstTeam ? firstTeam.id : yield* new StepFailure({ message: "No teams found in Linear workspace" });
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* The filter Linear's GraphQL API takes, built from the fields the user filled
|
|
1353
|
+
* in. A blank field contributes nothing, and "any" is how the status select
|
|
1354
|
+
* spells "do not filter on status".
|
|
1355
|
+
*/
|
|
1356
|
+
function buildIssueFilter(input) {
|
|
1357
|
+
const filter = {};
|
|
1358
|
+
if (input.linearAssigneeId) filter.assignee = { id: { eq: input.linearAssigneeId } };
|
|
1359
|
+
if (input.linearTeamId) filter.team = { id: { eq: input.linearTeamId } };
|
|
1360
|
+
if (input.linearStatus && input.linearStatus !== "any") filter.state = { type: { eq: linearStateType[input.linearStatus] } };
|
|
1361
|
+
if (input.linearLabel) filter.labels = { name: { eqIgnoreCase: input.linearLabel } };
|
|
1362
|
+
return Object.keys(filter).length > 0 ? filter : void 0;
|
|
1363
|
+
}
|
|
1364
|
+
function createLinear(createClient) {
|
|
1365
|
+
return defineIntegration({
|
|
1366
|
+
type: "linear",
|
|
1367
|
+
label: "Linear",
|
|
1368
|
+
description: "Create and manage issues in Linear",
|
|
1369
|
+
credentials: linearCredentialFields,
|
|
1370
|
+
test: async () => {
|
|
1371
|
+
const { testLinear } = await import("./test-DWVpiQNl.js");
|
|
1372
|
+
return (creds) => testLinear(creds, createClient);
|
|
1373
|
+
},
|
|
1374
|
+
actions: {
|
|
1375
|
+
"create-ticket": {
|
|
1376
|
+
label: "Create Ticket",
|
|
1377
|
+
description: "Create an issue in Linear",
|
|
1378
|
+
input: createTicketInput,
|
|
1379
|
+
output: createTicketOutput,
|
|
1380
|
+
configFields: [{
|
|
1381
|
+
key: "ticketTitle",
|
|
1382
|
+
label: "Ticket Title",
|
|
1383
|
+
type: "template-input",
|
|
1384
|
+
placeholder: "Bug report or {{NodeName.title}}",
|
|
1385
|
+
example: "Bug: Login button not working",
|
|
1386
|
+
required: true
|
|
1387
|
+
}, {
|
|
1388
|
+
key: "ticketDescription",
|
|
1389
|
+
label: "Description",
|
|
1390
|
+
type: "template-textarea",
|
|
1391
|
+
placeholder: "Description. Use {{NodeName.field}} to insert data from previous nodes.",
|
|
1392
|
+
rows: 4,
|
|
1393
|
+
example: "Users are unable to click the login button on mobile."
|
|
1394
|
+
}],
|
|
1395
|
+
handler: Effect.fn(function* (bag) {
|
|
1396
|
+
const { input } = bag;
|
|
1397
|
+
const credentials = yield* bag.credentials;
|
|
1398
|
+
const apiKey = credentials.LINEAR_API_KEY;
|
|
1399
|
+
if (!apiKey) return yield* new StepFailure({ message: "LINEAR_API_KEY is not configured. Please add it in Project Integrations." });
|
|
1400
|
+
const client = createClient(apiKey);
|
|
1401
|
+
const teamId = credentials.LINEAR_TEAM_ID || (yield* bag.step.run("first-team", firstTeamId(client)));
|
|
1402
|
+
const issue = yield* bag.step.run("create-ticket", callLinear("Failed to create ticket", async () => {
|
|
1403
|
+
const created = await client.createIssue({
|
|
1404
|
+
title: input.ticketTitle,
|
|
1405
|
+
description: input.ticketDescription,
|
|
1406
|
+
teamId
|
|
1407
|
+
});
|
|
1408
|
+
const made = created.issue ? await created.issue : void 0;
|
|
1409
|
+
return made ? {
|
|
1410
|
+
id: made.id,
|
|
1411
|
+
url: made.url,
|
|
1412
|
+
title: made.title
|
|
1413
|
+
} : void 0;
|
|
1414
|
+
}));
|
|
1415
|
+
if (!issue) return yield* new StepFailure({ message: "Failed to create issue" });
|
|
1416
|
+
return issue;
|
|
1417
|
+
})
|
|
1418
|
+
},
|
|
1419
|
+
"find-issues": {
|
|
1420
|
+
label: "Find Issues",
|
|
1421
|
+
description: "Search for issues in Linear",
|
|
1422
|
+
input: findIssuesInput,
|
|
1423
|
+
output: findIssuesOutput,
|
|
1424
|
+
configFields: [
|
|
1425
|
+
{
|
|
1426
|
+
key: "linearAssigneeId",
|
|
1427
|
+
label: "Assignee (User ID)",
|
|
1428
|
+
type: "template-input",
|
|
1429
|
+
placeholder: "user-id-123 or {{NodeName.userId}}"
|
|
1430
|
+
},
|
|
1431
|
+
{
|
|
1432
|
+
key: "linearTeamId",
|
|
1433
|
+
label: "Team ID (optional)",
|
|
1434
|
+
type: "template-input",
|
|
1435
|
+
placeholder: "team-id-456 or {{NodeName.teamId}}"
|
|
1436
|
+
},
|
|
1437
|
+
{
|
|
1438
|
+
key: "linearStatus",
|
|
1439
|
+
label: "Status (optional)",
|
|
1440
|
+
type: "select",
|
|
1441
|
+
defaultValue: "any",
|
|
1442
|
+
placeholder: "Any status",
|
|
1443
|
+
options: [
|
|
1444
|
+
{
|
|
1445
|
+
value: "any",
|
|
1446
|
+
label: "Any"
|
|
1447
|
+
},
|
|
1448
|
+
{
|
|
1449
|
+
value: "backlog",
|
|
1450
|
+
label: "Backlog"
|
|
1451
|
+
},
|
|
1452
|
+
{
|
|
1453
|
+
value: "todo",
|
|
1454
|
+
label: "Todo"
|
|
1455
|
+
},
|
|
1456
|
+
{
|
|
1457
|
+
value: "in_progress",
|
|
1458
|
+
label: "In Progress"
|
|
1459
|
+
},
|
|
1460
|
+
{
|
|
1461
|
+
value: "done",
|
|
1462
|
+
label: "Done"
|
|
1463
|
+
},
|
|
1464
|
+
{
|
|
1465
|
+
value: "canceled",
|
|
1466
|
+
label: "Canceled"
|
|
1467
|
+
}
|
|
1468
|
+
]
|
|
1469
|
+
},
|
|
1470
|
+
{
|
|
1471
|
+
key: "linearLabel",
|
|
1472
|
+
label: "Label (optional)",
|
|
1473
|
+
type: "template-input",
|
|
1474
|
+
placeholder: "bug, feature, etc. or {{NodeName.label}}"
|
|
1475
|
+
}
|
|
1476
|
+
],
|
|
1477
|
+
handler: Effect.fn(function* (bag) {
|
|
1478
|
+
const { input } = bag;
|
|
1479
|
+
const apiKey = (yield* bag.credentials).LINEAR_API_KEY;
|
|
1480
|
+
if (!apiKey) return yield* new StepFailure({ message: "LINEAR_API_KEY is not configured. Please add it in Project Integrations." });
|
|
1481
|
+
const client = createClient(apiKey);
|
|
1482
|
+
const issues = yield* bag.step.run("find-issues", Effect.tryPromise({
|
|
1483
|
+
try: async () => {
|
|
1484
|
+
const found = await client.issues({ filter: buildIssueFilter(input) });
|
|
1485
|
+
while (found.pageInfo.hasNextPage) await found.fetchNext();
|
|
1486
|
+
return await Promise.all(found.nodes.map(async (issue) => {
|
|
1487
|
+
const state = issue.state ? await issue.state : void 0;
|
|
1488
|
+
return {
|
|
1489
|
+
id: issue.id,
|
|
1490
|
+
title: issue.title,
|
|
1491
|
+
url: issue.url,
|
|
1492
|
+
state: state?.name || "Unknown",
|
|
1493
|
+
priority: issue.priority,
|
|
1494
|
+
assigneeId: issue.assigneeId ?? null
|
|
1495
|
+
};
|
|
1496
|
+
}));
|
|
1497
|
+
},
|
|
1498
|
+
catch: (error) => new StepFailure({ message: `Failed to find issues: ${describeLinearFailure(error)}` })
|
|
1499
|
+
}));
|
|
1500
|
+
return {
|
|
1501
|
+
issues,
|
|
1502
|
+
count: issues.length
|
|
1503
|
+
};
|
|
1504
|
+
})
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
const linear = createLinear(createLinearClient);
|
|
1510
|
+
//#endregion
|
|
1511
|
+
//#region src/resend/index.ts
|
|
1512
|
+
/**
|
|
1513
|
+
* The Resend integration: its credentials, its one action, and what that action
|
|
1514
|
+
* takes and gives back.
|
|
1515
|
+
*
|
|
1516
|
+
* One file, because only the server imports it. The editor gets this plugin's
|
|
1517
|
+
* metadata as JSON over `/api/extensions`, so nothing here reaches a browser
|
|
1518
|
+
* bundle and the system client below costs the browser nothing. The icon is the
|
|
1519
|
+
* exception, since a React component cannot be serialized: it stays in `ui.ts`,
|
|
1520
|
+
* which only the browser imports.
|
|
1521
|
+
*/
|
|
1522
|
+
const resendCredentialFields = {
|
|
1523
|
+
RESEND_API_KEY: {
|
|
1524
|
+
label: "API Key",
|
|
1525
|
+
type: "password",
|
|
1526
|
+
placeholder: "re_...",
|
|
1527
|
+
helpText: "Get your API key from ",
|
|
1528
|
+
helpLink: {
|
|
1529
|
+
text: "resend.com/api-keys",
|
|
1530
|
+
url: "https://resend.com/api-keys"
|
|
1531
|
+
}
|
|
1532
|
+
},
|
|
1533
|
+
RESEND_FROM_EMAIL: {
|
|
1534
|
+
label: "Default Sender",
|
|
1535
|
+
type: "text",
|
|
1536
|
+
placeholder: "Your Name <noreply@yourdomain.com>",
|
|
1537
|
+
helpText: "The name and email that will appear as the sender"
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
const TEST_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1541
|
+
/**
|
|
1542
|
+
* The Send Email config, as the step reads it.
|
|
1543
|
+
*
|
|
1544
|
+
* Every field is a string because that is what a resolved config field is: the
|
|
1545
|
+
* editor writes text, and a template variable resolves to text. Which of them a
|
|
1546
|
+
* builder has to fill in is stated in `configFields`; what this schema says is
|
|
1547
|
+
* what the step may read. `optionalKey` for a field left blank, which reaches a
|
|
1548
|
+
* step as an absent key.
|
|
1549
|
+
*/
|
|
1550
|
+
const sendEmailInput = Schema.Struct({
|
|
1551
|
+
emailTo: Schema.String,
|
|
1552
|
+
emailSubject: Schema.String,
|
|
1553
|
+
emailFrom: Schema.optionalKey(Schema.String),
|
|
1554
|
+
emailBody: Schema.optionalKey(Schema.String),
|
|
1555
|
+
emailHtml: Schema.optionalKey(Schema.String),
|
|
1556
|
+
emailContentMode: Schema.optionalKey(Schema.String),
|
|
1557
|
+
emailTemplateId: Schema.optionalKey(Schema.String),
|
|
1558
|
+
/** JSON the workflow author typed, parsed by the step. */
|
|
1559
|
+
emailTemplateVariables: Schema.optionalKey(Schema.String),
|
|
1560
|
+
emailCc: Schema.optionalKey(Schema.String),
|
|
1561
|
+
emailBcc: Schema.optionalKey(Schema.String),
|
|
1562
|
+
emailReplyTo: Schema.optionalKey(Schema.String),
|
|
1563
|
+
emailScheduledAt: Schema.optionalKey(Schema.String),
|
|
1564
|
+
emailTopicId: Schema.optionalKey(Schema.String),
|
|
1565
|
+
/** JSON the workflow author typed, parsed by the step. */
|
|
1566
|
+
emailTags: Schema.optionalKey(Schema.String),
|
|
1567
|
+
testBehavior: Schema.optionalKey(Schema.String),
|
|
1568
|
+
testEmailTo: Schema.optionalKey(Schema.String)
|
|
1569
|
+
});
|
|
1570
|
+
/**
|
|
1571
|
+
* What a sent email leaves for the nodes downstream of it.
|
|
1572
|
+
*
|
|
1573
|
+
* `optionalKey(NullOr(...))` on the way out, which is the one spelling that survives
|
|
1574
|
+
* both a key the handler leaves out and a null it writes where the system sent
|
|
1575
|
+
* nothing.
|
|
1576
|
+
*/
|
|
1577
|
+
const sendEmailOutput = Schema.Struct({
|
|
1578
|
+
id: Schema.String.annotate({ description: "Email ID" }),
|
|
1579
|
+
/** Absent on a real send: this is why a test run did not make one. */
|
|
1580
|
+
reasonCode: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Why a test run did not send" })))
|
|
1581
|
+
});
|
|
1582
|
+
function resolveResendTestBehavior(value) {
|
|
1583
|
+
return value === "send_to_test_email" ? "send_to_test_email" : "log_only";
|
|
1584
|
+
}
|
|
1585
|
+
function isValidTestEmailAddress(value) {
|
|
1586
|
+
return TEST_EMAIL_PATTERN.test(value);
|
|
1587
|
+
}
|
|
1588
|
+
const emailTagsSchema = Schema.mutable(Schema.Array(Schema.Struct({
|
|
1589
|
+
name: Schema.String,
|
|
1590
|
+
value: Schema.String
|
|
1591
|
+
})));
|
|
1592
|
+
const templateVariablesSchema = Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Finite]));
|
|
1593
|
+
const decodeEmailTags = Schema.decodeUnknownResult(emailTagsSchema, { errors: "all" });
|
|
1594
|
+
const decodeTemplateVariables = Schema.decodeUnknownResult(templateVariablesSchema, { errors: "all" });
|
|
1595
|
+
function parseTags(tagsJson) {
|
|
1596
|
+
let parsed;
|
|
1597
|
+
try {
|
|
1598
|
+
parsed = JSON.parse(tagsJson);
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
console.error("[Resend] Failed to parse tags JSON:", error);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
const result = decodeEmailTags(parsed);
|
|
1604
|
+
if (Result.isFailure(result)) {
|
|
1605
|
+
console.error("[Resend] Tags JSON must be a list of { name, value } entries:", result.failure.message);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
return result.success;
|
|
1609
|
+
}
|
|
1610
|
+
function parseTemplateVariables(templateVariables) {
|
|
1611
|
+
if (!templateVariables) return;
|
|
1612
|
+
let parsed;
|
|
1613
|
+
try {
|
|
1614
|
+
parsed = JSON.parse(templateVariables);
|
|
1615
|
+
} catch (error) {
|
|
1616
|
+
console.error("[Resend] Failed to parse template variables JSON:", error);
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
const result = decodeTemplateVariables(parsed);
|
|
1620
|
+
if (Result.isFailure(result)) {
|
|
1621
|
+
console.error("[Resend] Template variables JSON must map names to strings or numbers:", result.failure.message);
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
return result.success;
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* The body Resend is sent, or the failure that says why one could not be built.
|
|
1628
|
+
*
|
|
1629
|
+
* The three content modes differ only in which fields they add to a common set,
|
|
1630
|
+
* and each names the field it cannot do without, so the message tells an author
|
|
1631
|
+
* which box to fill in.
|
|
1632
|
+
*/
|
|
1633
|
+
function buildEmailPayload(input, senderEmail, recipients) {
|
|
1634
|
+
const basePayload = {
|
|
1635
|
+
from: senderEmail,
|
|
1636
|
+
to: recipients.to,
|
|
1637
|
+
subject: input.emailSubject,
|
|
1638
|
+
...omitBy({
|
|
1639
|
+
cc: recipients.cc,
|
|
1640
|
+
bcc: recipients.bcc,
|
|
1641
|
+
reply_to: input.emailReplyTo,
|
|
1642
|
+
scheduled_at: input.emailScheduledAt,
|
|
1643
|
+
topic_id: input.emailTopicId,
|
|
1644
|
+
tags: input.emailTags ? parseTags(input.emailTags) : void 0
|
|
1645
|
+
}, isNil)
|
|
1646
|
+
};
|
|
1647
|
+
const contentMode = input.emailContentMode || "text";
|
|
1648
|
+
if (contentMode === "template") return input.emailTemplateId ? Effect.succeed({
|
|
1649
|
+
...basePayload,
|
|
1650
|
+
template: {
|
|
1651
|
+
id: input.emailTemplateId,
|
|
1652
|
+
...omitBy({ variables: parseTemplateVariables(input.emailTemplateVariables) }, isNil)
|
|
1653
|
+
}
|
|
1654
|
+
}) : Effect.fail(new StepFailure({ message: "Template mode requires emailTemplateId." }));
|
|
1655
|
+
if (contentMode === "html") return input.emailHtml ? Effect.succeed({
|
|
1656
|
+
...basePayload,
|
|
1657
|
+
html: input.emailHtml,
|
|
1658
|
+
...omitBy({ text: input.emailBody }, isNil)
|
|
1659
|
+
}) : Effect.fail(new StepFailure({ message: "HTML mode requires emailHtml." }));
|
|
1660
|
+
return input.emailBody ? Effect.succeed({
|
|
1661
|
+
...basePayload,
|
|
1662
|
+
text: input.emailBody,
|
|
1663
|
+
...omitBy({ html: input.emailHtml }, isNil)
|
|
1664
|
+
}) : Effect.fail(new StepFailure({ message: "Text mode requires emailBody." }));
|
|
1665
|
+
}
|
|
1666
|
+
const resend = defineIntegration({
|
|
1667
|
+
type: "resend",
|
|
1668
|
+
label: "Resend",
|
|
1669
|
+
description: "Send transactional emails",
|
|
1670
|
+
credentials: resendCredentialFields,
|
|
1671
|
+
test: async () => (await import("./test-B8I6nxMN.js")).testResend,
|
|
1672
|
+
actions: { "send-email": {
|
|
1673
|
+
label: "Send Email",
|
|
1674
|
+
description: "Send an email via Resend",
|
|
1675
|
+
input: sendEmailInput,
|
|
1676
|
+
output: sendEmailOutput,
|
|
1677
|
+
configFields: [
|
|
1678
|
+
{
|
|
1679
|
+
key: "emailFrom",
|
|
1680
|
+
label: "From (Sender)",
|
|
1681
|
+
type: "template-input",
|
|
1682
|
+
placeholder: "Your Name <noreply@example.com>",
|
|
1683
|
+
example: "Support <support@example.com>"
|
|
1684
|
+
},
|
|
1685
|
+
{
|
|
1686
|
+
key: "emailTo",
|
|
1687
|
+
label: "To",
|
|
1688
|
+
type: "template-input",
|
|
1689
|
+
placeholder: "recipient@example.com",
|
|
1690
|
+
example: "user@example.com",
|
|
1691
|
+
required: true
|
|
1692
|
+
},
|
|
1693
|
+
{
|
|
1694
|
+
key: "testBehavior",
|
|
1695
|
+
label: "Test Mode Behavior",
|
|
1696
|
+
type: "select",
|
|
1697
|
+
defaultValue: "log_only",
|
|
1698
|
+
options: [{
|
|
1699
|
+
value: "log_only",
|
|
1700
|
+
label: "Log only (do nothing)"
|
|
1701
|
+
}, {
|
|
1702
|
+
value: "send_to_test_email",
|
|
1703
|
+
label: "Send to test email"
|
|
1704
|
+
}]
|
|
1705
|
+
},
|
|
1706
|
+
{
|
|
1707
|
+
key: "testEmailTo",
|
|
1708
|
+
label: "Test Email Address",
|
|
1709
|
+
type: "text",
|
|
1710
|
+
placeholder: "test@example.com",
|
|
1711
|
+
literal: true,
|
|
1712
|
+
showWhen: {
|
|
1713
|
+
field: "testBehavior",
|
|
1714
|
+
equals: "send_to_test_email"
|
|
1715
|
+
}
|
|
1716
|
+
},
|
|
1717
|
+
{
|
|
1718
|
+
key: "emailSubject",
|
|
1719
|
+
label: "Subject",
|
|
1720
|
+
type: "template-input",
|
|
1721
|
+
placeholder: "Subject or {{NodeName.title}}",
|
|
1722
|
+
example: "Hello from my workflow",
|
|
1723
|
+
required: true
|
|
1724
|
+
},
|
|
1725
|
+
{
|
|
1726
|
+
key: "emailContentMode",
|
|
1727
|
+
label: "Content Mode",
|
|
1728
|
+
type: "select",
|
|
1729
|
+
defaultValue: "text",
|
|
1730
|
+
options: [
|
|
1731
|
+
{
|
|
1732
|
+
value: "text",
|
|
1733
|
+
label: "Text"
|
|
1734
|
+
},
|
|
1735
|
+
{
|
|
1736
|
+
value: "html",
|
|
1737
|
+
label: "HTML"
|
|
1738
|
+
},
|
|
1739
|
+
{
|
|
1740
|
+
value: "template",
|
|
1741
|
+
label: "Template"
|
|
1742
|
+
}
|
|
1743
|
+
]
|
|
1744
|
+
},
|
|
1745
|
+
{
|
|
1746
|
+
key: "emailBody",
|
|
1747
|
+
label: "Text Body",
|
|
1748
|
+
type: "template-textarea",
|
|
1749
|
+
placeholder: "Email content or {{NodeName.description}}",
|
|
1750
|
+
rows: 5,
|
|
1751
|
+
example: "This is the email body content.",
|
|
1752
|
+
required: true,
|
|
1753
|
+
showWhen: {
|
|
1754
|
+
field: "emailContentMode",
|
|
1755
|
+
equals: "text"
|
|
1756
|
+
}
|
|
1757
|
+
},
|
|
1758
|
+
{
|
|
1759
|
+
key: "emailHtml",
|
|
1760
|
+
label: "HTML Body",
|
|
1761
|
+
type: "template-textarea",
|
|
1762
|
+
placeholder: "<p>Hello {{NodeName.name}}</p>",
|
|
1763
|
+
rows: 8,
|
|
1764
|
+
showWhen: {
|
|
1765
|
+
field: "emailContentMode",
|
|
1766
|
+
equals: "html"
|
|
1767
|
+
}
|
|
1768
|
+
},
|
|
1769
|
+
{
|
|
1770
|
+
key: "emailTemplateId",
|
|
1771
|
+
label: "Template ID",
|
|
1772
|
+
type: "template-input",
|
|
1773
|
+
placeholder: "tpl_xxxxxxxx",
|
|
1774
|
+
showWhen: {
|
|
1775
|
+
field: "emailContentMode",
|
|
1776
|
+
equals: "template"
|
|
1777
|
+
}
|
|
1778
|
+
},
|
|
1779
|
+
{
|
|
1780
|
+
key: "emailTemplateVariables",
|
|
1781
|
+
label: "Template Variables (JSON)",
|
|
1782
|
+
type: "template-textarea",
|
|
1783
|
+
rows: 6,
|
|
1784
|
+
placeholder: "{\"FIRST_NAME\":\"Alice\",\"APPOINTMENT_AT\":\"2026-03-10\"}",
|
|
1785
|
+
showWhen: {
|
|
1786
|
+
field: "emailContentMode",
|
|
1787
|
+
equals: "template"
|
|
1788
|
+
}
|
|
1789
|
+
},
|
|
1790
|
+
{
|
|
1791
|
+
type: "group",
|
|
1792
|
+
label: "Additional Recipients",
|
|
1793
|
+
fields: [
|
|
1794
|
+
{
|
|
1795
|
+
key: "emailCc",
|
|
1796
|
+
label: "CC",
|
|
1797
|
+
type: "template-input",
|
|
1798
|
+
placeholder: "cc@example.com",
|
|
1799
|
+
example: "manager@example.com"
|
|
1800
|
+
},
|
|
1801
|
+
{
|
|
1802
|
+
key: "emailBcc",
|
|
1803
|
+
label: "BCC",
|
|
1804
|
+
type: "template-input",
|
|
1805
|
+
placeholder: "bcc@example.com",
|
|
1806
|
+
example: "archive@example.com"
|
|
1807
|
+
},
|
|
1808
|
+
{
|
|
1809
|
+
key: "emailReplyTo",
|
|
1810
|
+
label: "Reply-To",
|
|
1811
|
+
type: "template-input",
|
|
1812
|
+
placeholder: "reply@example.com",
|
|
1813
|
+
example: "support@example.com"
|
|
1814
|
+
}
|
|
1815
|
+
]
|
|
1816
|
+
},
|
|
1817
|
+
{
|
|
1818
|
+
type: "group",
|
|
1819
|
+
label: "Scheduling",
|
|
1820
|
+
fields: [{
|
|
1821
|
+
key: "emailScheduledAt",
|
|
1822
|
+
label: "Schedule At (ISO 8601)",
|
|
1823
|
+
type: "template-input",
|
|
1824
|
+
placeholder: "2024-12-25T09:00:00Z",
|
|
1825
|
+
example: "2024-12-25T09:00:00Z"
|
|
1826
|
+
}, {
|
|
1827
|
+
key: "emailTopicId",
|
|
1828
|
+
label: "Topic ID",
|
|
1829
|
+
type: "template-input",
|
|
1830
|
+
placeholder: "topic_abc123",
|
|
1831
|
+
example: "topic_abc123"
|
|
1832
|
+
}]
|
|
1833
|
+
},
|
|
1834
|
+
{
|
|
1835
|
+
type: "group",
|
|
1836
|
+
label: "Tags",
|
|
1837
|
+
fields: [{
|
|
1838
|
+
key: "emailTags",
|
|
1839
|
+
label: "",
|
|
1840
|
+
type: "key-value"
|
|
1841
|
+
}]
|
|
1842
|
+
}
|
|
1843
|
+
],
|
|
1844
|
+
handler: Effect.fn(function* (bag) {
|
|
1845
|
+
const { input } = bag;
|
|
1846
|
+
const idempotencyKey = bag.executionId;
|
|
1847
|
+
const syntheticIdSuffix = idempotencyKey ?? "no_execution";
|
|
1848
|
+
const testBehavior = resolveResendTestBehavior(input.testBehavior);
|
|
1849
|
+
if (bag.runMode === "test" && testBehavior === "log_only") return {
|
|
1850
|
+
id: `resend:test-log-only:${syntheticIdSuffix}`,
|
|
1851
|
+
reasonCode: "test_mode_log_only"
|
|
1852
|
+
};
|
|
1853
|
+
const testRecipient = input.testEmailTo?.trim() ?? "";
|
|
1854
|
+
const routeToTestRecipient = bag.runMode === "test" && testBehavior === "send_to_test_email";
|
|
1855
|
+
if (routeToTestRecipient && testRecipient.length === 0) return {
|
|
1856
|
+
id: `resend:test-log-fallback:${syntheticIdSuffix}`,
|
|
1857
|
+
reasonCode: "test_mode_log_fallback_missing_test_email"
|
|
1858
|
+
};
|
|
1859
|
+
if (routeToTestRecipient && !isValidTestEmailAddress(testRecipient)) return {
|
|
1860
|
+
id: `resend:test-log-fallback:${syntheticIdSuffix}`,
|
|
1861
|
+
reasonCode: "test_mode_log_fallback_invalid_test_email"
|
|
1862
|
+
};
|
|
1863
|
+
const credentials = yield* bag.credentials;
|
|
1864
|
+
const apiKey = credentials.RESEND_API_KEY;
|
|
1865
|
+
if (!apiKey) return yield* new StepFailure({ message: "RESEND_API_KEY is not configured. Please add it in Project Integrations." });
|
|
1866
|
+
const senderEmail = input.emailFrom || credentials.RESEND_FROM_EMAIL;
|
|
1867
|
+
if (!senderEmail) return yield* new StepFailure({ message: "No sender is configured. Please add it in the action or in Project Integrations." });
|
|
1868
|
+
const payload = yield* buildEmailPayload(input, senderEmail, routeToTestRecipient ? { to: testRecipient } : {
|
|
1869
|
+
to: input.emailTo,
|
|
1870
|
+
cc: input.emailCc,
|
|
1871
|
+
bcc: input.emailBcc
|
|
1872
|
+
});
|
|
1873
|
+
return { id: (yield* bag.step.run("send", sendResendEmail(apiKey, payload, idempotencyKey).pipe(Effect.mapError((error) => new StepFailure({ message: `Failed to send email: ${describeResendFailure(error)}` }))))).id };
|
|
1874
|
+
})
|
|
1875
|
+
} }
|
|
1876
|
+
});
|
|
1877
|
+
//#endregion
|
|
1878
|
+
//#region src/slack/index.ts
|
|
1879
|
+
/**
|
|
1880
|
+
* The Slack integration: its credentials, its one action, and what that action
|
|
1881
|
+
* does.
|
|
1882
|
+
*
|
|
1883
|
+
* One file, because only the server imports it. The editor gets this plugin's
|
|
1884
|
+
* metadata as JSON over `/api/extensions`, so nothing here reaches a browser
|
|
1885
|
+
* bundle. The icon is the exception, since a React component cannot be
|
|
1886
|
+
* serialized: it stays in `ui.ts`, which only the browser imports.
|
|
1887
|
+
*/
|
|
1888
|
+
const slackCredentialFields = { SLACK_API_KEY: {
|
|
1889
|
+
label: "Bot Token",
|
|
1890
|
+
type: "password",
|
|
1891
|
+
placeholder: "xoxb-...",
|
|
1892
|
+
helpText: "Create a Slack app and get your Bot Token from ",
|
|
1893
|
+
helpLink: {
|
|
1894
|
+
text: "api.slack.com/apps",
|
|
1895
|
+
url: "https://api.slack.com/apps"
|
|
1896
|
+
}
|
|
1897
|
+
} };
|
|
1898
|
+
const postMessageSchema = Schema.Struct({
|
|
1899
|
+
ts: Schema.String,
|
|
1900
|
+
channel: Schema.String
|
|
1901
|
+
});
|
|
1902
|
+
/**
|
|
1903
|
+
* The Send Slack Message config, as the step reads it.
|
|
1904
|
+
*
|
|
1905
|
+
* Every field is a string because that is what a resolved config field is: the
|
|
1906
|
+
* editor writes text, and a template variable resolves to text. `optionalKey` for
|
|
1907
|
+
* a field a builder may leave blank, which reaches a step as an absent key.
|
|
1908
|
+
*/
|
|
1909
|
+
const sendSlackMessageInput = Schema.Struct({
|
|
1910
|
+
slackChannel: Schema.String,
|
|
1911
|
+
slackMessage: Schema.String,
|
|
1912
|
+
testBehavior: Schema.optionalKey(Schema.String)
|
|
1913
|
+
});
|
|
1914
|
+
/**
|
|
1915
|
+
* What a posted message leaves for the nodes downstream of it.
|
|
1916
|
+
*
|
|
1917
|
+
* `optionalKey(NullOr(...))` on the way out, which is the one spelling that survives
|
|
1918
|
+
* both a key the handler leaves out and a null it writes where the system sent
|
|
1919
|
+
* nothing.
|
|
1920
|
+
*/
|
|
1921
|
+
const sendSlackMessageOutput = Schema.Struct({
|
|
1922
|
+
ts: Schema.String.annotate({ description: "Message timestamp" }),
|
|
1923
|
+
channel: Schema.String.annotate({ description: "Channel ID" }),
|
|
1924
|
+
/** Absent on a real send: this is why a test run did not make one. */
|
|
1925
|
+
reasonCode: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Why a test run did not send" })))
|
|
1926
|
+
});
|
|
1927
|
+
function resolveSlackTestBehavior(value) {
|
|
1928
|
+
return value === "send_message" ? "send_message" : "log_only";
|
|
1929
|
+
}
|
|
1930
|
+
const slack = defineIntegration({
|
|
1931
|
+
type: "slack",
|
|
1932
|
+
label: "Slack",
|
|
1933
|
+
description: "Send messages to Slack channels",
|
|
1934
|
+
credentials: slackCredentialFields,
|
|
1935
|
+
test: async () => (await import("./test-8wDSAiFQ.js")).testSlack,
|
|
1936
|
+
actions: { "send-message": {
|
|
1937
|
+
label: "Send Slack Message",
|
|
1938
|
+
description: "Send a message to a Slack channel",
|
|
1939
|
+
input: sendSlackMessageInput,
|
|
1940
|
+
output: sendSlackMessageOutput,
|
|
1941
|
+
configFields: [
|
|
1942
|
+
{
|
|
1943
|
+
key: "slackChannel",
|
|
1944
|
+
label: "Channel",
|
|
1945
|
+
type: "text",
|
|
1946
|
+
placeholder: "#general or {{NodeName.channel}}",
|
|
1947
|
+
example: "#general",
|
|
1948
|
+
required: true
|
|
1949
|
+
},
|
|
1950
|
+
{
|
|
1951
|
+
key: "slackMessage",
|
|
1952
|
+
label: "Message",
|
|
1953
|
+
type: "template-textarea",
|
|
1954
|
+
placeholder: "Your message. Use {{NodeName.field}} to insert data from previous nodes.",
|
|
1955
|
+
rows: 4,
|
|
1956
|
+
example: "Hello from my workflow!",
|
|
1957
|
+
required: true
|
|
1958
|
+
},
|
|
1959
|
+
{
|
|
1960
|
+
key: "testBehavior",
|
|
1961
|
+
label: "Test Mode Behavior",
|
|
1962
|
+
type: "select",
|
|
1963
|
+
defaultValue: "log_only",
|
|
1964
|
+
options: [{
|
|
1965
|
+
value: "log_only",
|
|
1966
|
+
label: "Log only (do nothing)"
|
|
1967
|
+
}, {
|
|
1968
|
+
value: "send_message",
|
|
1969
|
+
label: "Send real Slack message"
|
|
1970
|
+
}]
|
|
1971
|
+
}
|
|
1972
|
+
],
|
|
1973
|
+
handler: Effect.fn(function* (bag) {
|
|
1974
|
+
const { input } = bag;
|
|
1975
|
+
const testBehavior = resolveSlackTestBehavior(input.testBehavior);
|
|
1976
|
+
if (bag.runMode === "test" && testBehavior === "log_only") return {
|
|
1977
|
+
ts: "",
|
|
1978
|
+
channel: input.slackChannel,
|
|
1979
|
+
reasonCode: "test_mode_log_only"
|
|
1980
|
+
};
|
|
1981
|
+
const apiKey = (yield* bag.credentials).SLACK_API_KEY;
|
|
1982
|
+
if (!apiKey) return yield* new StepFailure({ message: "SLACK_API_KEY is not configured. Please add it in Project Integrations." });
|
|
1983
|
+
const posted = yield* bag.step.run("post", callSlack(apiKey, "chat.postMessage", postMessageSchema, { body: {
|
|
1984
|
+
channel: input.slackChannel,
|
|
1985
|
+
text: input.slackMessage
|
|
1986
|
+
} }).pipe(Effect.mapError((error) => new StepFailure({ message: `Failed to send Slack message: ${describeSlackFailure(error)}` }))));
|
|
1987
|
+
return {
|
|
1988
|
+
ts: posted.ts,
|
|
1989
|
+
channel: posted.channel
|
|
1990
|
+
};
|
|
1991
|
+
})
|
|
1992
|
+
} }
|
|
1993
|
+
});
|
|
1994
|
+
//#endregion
|
|
1995
|
+
//#region src/twilio/index.ts
|
|
1996
|
+
/**
|
|
1997
|
+
* The Twilio integration: its credentials, its actions, and what each action
|
|
1998
|
+
* does.
|
|
1999
|
+
*
|
|
2000
|
+
* One file, because only the server imports it. The editor gets this plugin's
|
|
2001
|
+
* metadata as JSON over `/api/extensions`, so nothing here reaches a browser
|
|
2002
|
+
* bundle and the system client below costs the browser nothing. The icon is the
|
|
2003
|
+
* exception, since a React component cannot be serialized: it stays in `ui.ts`,
|
|
2004
|
+
* which only the browser imports.
|
|
2005
|
+
*/
|
|
2006
|
+
const twilioCredentialFields = {
|
|
2007
|
+
TWILIO_ACCOUNT_SID: {
|
|
2008
|
+
label: "Account SID",
|
|
2009
|
+
type: "text",
|
|
2010
|
+
placeholder: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
2011
|
+
helpText: "Find this in your Twilio Console."
|
|
2012
|
+
},
|
|
2013
|
+
TWILIO_AUTH_TOKEN: {
|
|
2014
|
+
label: "Auth Token",
|
|
2015
|
+
type: "password",
|
|
2016
|
+
placeholder: "••••••••",
|
|
2017
|
+
helpText: "Keep this secret. Used for Basic auth to Twilio API."
|
|
2018
|
+
},
|
|
2019
|
+
TWILIO_FROM_NUMBER: {
|
|
2020
|
+
label: "Default From Number",
|
|
2021
|
+
type: "text",
|
|
2022
|
+
placeholder: "+15551234567",
|
|
2023
|
+
helpText: "Optional fallback sender number."
|
|
2024
|
+
},
|
|
2025
|
+
TWILIO_MESSAGING_SERVICE_SID: {
|
|
2026
|
+
label: "Default Messaging Service SID",
|
|
2027
|
+
type: "text",
|
|
2028
|
+
placeholder: "MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
2029
|
+
helpText: "Optional fallback if From is not provided."
|
|
2030
|
+
}
|
|
2031
|
+
};
|
|
2032
|
+
const E164_PHONE_PATTERN = /^\+[1-9]\d{6,14}$/;
|
|
2033
|
+
/**
|
|
2034
|
+
* A list carried by one text field, which is the only shape a config field has.
|
|
2035
|
+
*
|
|
2036
|
+
* The split is a transform on the schema rather than a helper the handler calls:
|
|
2037
|
+
* a step's input is decoded through its canonical JSON codec, so a handler
|
|
2038
|
+
* receives the list and nothing in it has to know the field was ever text.
|
|
2039
|
+
*/
|
|
2040
|
+
const commaSeparatedUrls = Schema.String.pipe(Schema.decodeTo(Schema.Array(Schema.String), SchemaTransformation.transform({
|
|
2041
|
+
decode: (value) => value.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0),
|
|
2042
|
+
encode: (entries) => entries.join(",")
|
|
2043
|
+
})));
|
|
2044
|
+
/**
|
|
2045
|
+
* The Send SMS config, as the step reads it.
|
|
2046
|
+
*
|
|
2047
|
+
* Almost every field is a string because that is what a resolved config field
|
|
2048
|
+
* is: the editor writes text, and a template variable resolves to text. Which of
|
|
2049
|
+
* them a builder has to fill in is stated in `configFields`; what this schema
|
|
2050
|
+
* says is what the step may read.
|
|
2051
|
+
*
|
|
2052
|
+
* `optional`, not `optionalKey`: the engine resolves a node's templates into
|
|
2053
|
+
* every config key the action declares, so a field left blank arrives as a key
|
|
2054
|
+
* holding `undefined` rather than as no key at all. Exact-optional semantics
|
|
2055
|
+
* would reject the config a real run builds.
|
|
2056
|
+
*/
|
|
2057
|
+
const sendSmsInput = Schema.Struct({
|
|
2058
|
+
smsTo: Schema.String,
|
|
2059
|
+
smsBody: Schema.String,
|
|
2060
|
+
smsFrom: Schema.optional(Schema.String),
|
|
2061
|
+
smsMessagingServiceSid: Schema.optional(Schema.String),
|
|
2062
|
+
smsStatusCallback: Schema.optional(Schema.String),
|
|
2063
|
+
smsMediaUrls: Schema.optional(commaSeparatedUrls),
|
|
2064
|
+
testBehavior: Schema.optional(Schema.String),
|
|
2065
|
+
testPhoneTo: Schema.optional(Schema.String)
|
|
2066
|
+
});
|
|
2067
|
+
/**
|
|
2068
|
+
* What a sent message leaves for the nodes downstream of it.
|
|
2069
|
+
*
|
|
2070
|
+
* `optionalKey(NullOr(...))` throughout, which is the one spelling that survives
|
|
2071
|
+
* everything: a key the handler leaves out on the test-mode paths, and a null it
|
|
2072
|
+
* writes where Twilio sent no number. The encode refuses a key that is present and
|
|
2073
|
+
* holds `undefined`, which is why the handler writes the null.
|
|
2074
|
+
*/
|
|
2075
|
+
const sendSmsOutput = Schema.Struct({
|
|
2076
|
+
sid: Schema.String.annotate({ description: "Message SID" }),
|
|
2077
|
+
status: Schema.String.annotate({ description: "Delivery status" }),
|
|
2078
|
+
to: Schema.String.annotate({ description: "Recipient phone number" }),
|
|
2079
|
+
from: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Sender phone number" }))),
|
|
2080
|
+
messagingServiceSid: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Messaging Service SID" }))),
|
|
2081
|
+
/** Absent on a real send: this is why a test run did not make one. */
|
|
2082
|
+
reasonCode: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Why a test run did not send" })))
|
|
2083
|
+
});
|
|
2084
|
+
function resolveTwilioTestBehavior(value) {
|
|
2085
|
+
return value === "send_to_test_phone" ? "send_to_test_phone" : "log_only";
|
|
2086
|
+
}
|
|
2087
|
+
const twilio = defineIntegration({
|
|
2088
|
+
type: "twilio",
|
|
2089
|
+
label: "Twilio",
|
|
2090
|
+
description: "Send SMS messages with Twilio Programmable Messaging",
|
|
2091
|
+
credentials: twilioCredentialFields,
|
|
2092
|
+
test: async () => (await import("./test-S28s7Z3a.js")).testTwilio,
|
|
2093
|
+
actions: { "send-sms": {
|
|
2094
|
+
label: "Send SMS",
|
|
2095
|
+
description: "Send an SMS via Twilio",
|
|
2096
|
+
input: sendSmsInput,
|
|
2097
|
+
output: sendSmsOutput,
|
|
2098
|
+
configFields: [
|
|
2099
|
+
{
|
|
2100
|
+
key: "smsTo",
|
|
2101
|
+
label: "To",
|
|
2102
|
+
type: "template-input",
|
|
2103
|
+
placeholder: "+15551234567",
|
|
2104
|
+
required: true
|
|
2105
|
+
},
|
|
2106
|
+
{
|
|
2107
|
+
key: "testBehavior",
|
|
2108
|
+
label: "Test Mode Behavior",
|
|
2109
|
+
type: "select",
|
|
2110
|
+
defaultValue: "log_only",
|
|
2111
|
+
options: [{
|
|
2112
|
+
value: "log_only",
|
|
2113
|
+
label: "Log only (do nothing)"
|
|
2114
|
+
}, {
|
|
2115
|
+
value: "send_to_test_phone",
|
|
2116
|
+
label: "Send to test phone"
|
|
2117
|
+
}]
|
|
2118
|
+
},
|
|
2119
|
+
{
|
|
2120
|
+
key: "testPhoneTo",
|
|
2121
|
+
label: "Test Phone Number",
|
|
2122
|
+
type: "text",
|
|
2123
|
+
placeholder: "+15557654321",
|
|
2124
|
+
literal: true,
|
|
2125
|
+
showWhen: {
|
|
2126
|
+
field: "testBehavior",
|
|
2127
|
+
equals: "send_to_test_phone"
|
|
2128
|
+
}
|
|
2129
|
+
},
|
|
2130
|
+
{
|
|
2131
|
+
key: "smsBody",
|
|
2132
|
+
label: "Message",
|
|
2133
|
+
type: "template-textarea",
|
|
2134
|
+
placeholder: "Hi from workflow {{PreviousNode.value}}",
|
|
2135
|
+
rows: 4,
|
|
2136
|
+
required: true
|
|
2137
|
+
},
|
|
2138
|
+
{
|
|
2139
|
+
type: "group",
|
|
2140
|
+
label: "Sender",
|
|
2141
|
+
defaultExpanded: true,
|
|
2142
|
+
fields: [{
|
|
2143
|
+
key: "smsFrom",
|
|
2144
|
+
label: "From Number",
|
|
2145
|
+
type: "template-input",
|
|
2146
|
+
placeholder: "+15557654321"
|
|
2147
|
+
}, {
|
|
2148
|
+
key: "smsMessagingServiceSid",
|
|
2149
|
+
label: "Messaging Service SID",
|
|
2150
|
+
type: "template-input",
|
|
2151
|
+
placeholder: "MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
2152
|
+
}]
|
|
2153
|
+
},
|
|
2154
|
+
{
|
|
2155
|
+
type: "group",
|
|
2156
|
+
label: "Advanced",
|
|
2157
|
+
fields: [{
|
|
2158
|
+
key: "smsStatusCallback",
|
|
2159
|
+
label: "Status Callback URL",
|
|
2160
|
+
type: "template-input",
|
|
2161
|
+
placeholder: "https://example.com/twilio/status"
|
|
2162
|
+
}, {
|
|
2163
|
+
key: "smsMediaUrls",
|
|
2164
|
+
label: "Media URLs (comma separated)",
|
|
2165
|
+
type: "template-input",
|
|
2166
|
+
placeholder: "https://example.com/image.png"
|
|
2167
|
+
}]
|
|
2168
|
+
}
|
|
2169
|
+
],
|
|
2170
|
+
handler: Effect.fn(function* (bag) {
|
|
2171
|
+
const { input } = bag;
|
|
2172
|
+
const executionId = bag.executionId ?? "no_execution";
|
|
2173
|
+
const testBehavior = resolveTwilioTestBehavior(input.testBehavior);
|
|
2174
|
+
if (bag.runMode === "test" && testBehavior === "log_only") return {
|
|
2175
|
+
sid: `twilio:test-log-only:${executionId}`,
|
|
2176
|
+
status: "queued",
|
|
2177
|
+
to: input.smsTo,
|
|
2178
|
+
reasonCode: "test_mode_log_only"
|
|
2179
|
+
};
|
|
2180
|
+
const testPhone = input.testPhoneTo?.trim() ?? "";
|
|
2181
|
+
const routeToTestPhone = bag.runMode === "test" && testBehavior === "send_to_test_phone";
|
|
2182
|
+
if (routeToTestPhone && testPhone.length === 0) return {
|
|
2183
|
+
sid: `twilio:test-log-fallback:${executionId}`,
|
|
2184
|
+
status: "queued",
|
|
2185
|
+
to: input.smsTo,
|
|
2186
|
+
reasonCode: "test_mode_log_fallback_missing_test_phone"
|
|
2187
|
+
};
|
|
2188
|
+
if (routeToTestPhone && !E164_PHONE_PATTERN.test(testPhone)) return {
|
|
2189
|
+
sid: `twilio:test-log-fallback:${executionId}`,
|
|
2190
|
+
status: "queued",
|
|
2191
|
+
to: input.smsTo,
|
|
2192
|
+
reasonCode: "test_mode_log_fallback_invalid_test_phone"
|
|
2193
|
+
};
|
|
2194
|
+
const credentials = yield* bag.credentials;
|
|
2195
|
+
const accountSid = credentials.TWILIO_ACCOUNT_SID;
|
|
2196
|
+
const authToken = credentials.TWILIO_AUTH_TOKEN;
|
|
2197
|
+
if (!(accountSid && authToken)) return yield* new StepFailure({ message: "TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are required. Add them in Project Integrations." });
|
|
2198
|
+
const senderFrom = input.smsFrom || credentials.TWILIO_FROM_NUMBER;
|
|
2199
|
+
const senderMessagingServiceSid = input.smsMessagingServiceSid || credentials.TWILIO_MESSAGING_SERVICE_SID;
|
|
2200
|
+
if (!(senderFrom || senderMessagingServiceSid)) return yield* new StepFailure({ message: "Either From number or Messaging Service SID is required. Configure one in the action or integration settings." });
|
|
2201
|
+
const recipient = routeToTestPhone ? testPhone : input.smsTo;
|
|
2202
|
+
if (!(recipient && input.smsBody)) return yield* new StepFailure({ message: "smsTo and smsBody are required" });
|
|
2203
|
+
const mediaUrls = input.smsMediaUrls ?? [];
|
|
2204
|
+
const message = yield* bag.step.run("send", createTwilioMessage({
|
|
2205
|
+
accountSid,
|
|
2206
|
+
authToken
|
|
2207
|
+
}, {
|
|
2208
|
+
To: recipient,
|
|
2209
|
+
Body: input.smsBody,
|
|
2210
|
+
From: senderFrom || void 0,
|
|
2211
|
+
MessagingServiceSid: senderMessagingServiceSid || void 0,
|
|
2212
|
+
StatusCallback: input.smsStatusCallback || void 0,
|
|
2213
|
+
MediaUrl: mediaUrls.length > 0 ? [...mediaUrls] : void 0
|
|
2214
|
+
}).pipe(Effect.mapError((error) => new StepFailure({ message: describeTwilioFailure(error) }))));
|
|
2215
|
+
return {
|
|
2216
|
+
sid: message.sid,
|
|
2217
|
+
status: message.status,
|
|
2218
|
+
to: message.to,
|
|
2219
|
+
from: message.from ?? null,
|
|
2220
|
+
messagingServiceSid: message.messaging_service_sid ?? null
|
|
2221
|
+
};
|
|
2222
|
+
})
|
|
2223
|
+
} }
|
|
2224
|
+
});
|
|
2225
|
+
//#endregion
|
|
2226
|
+
//#region src/index.ts
|
|
2227
|
+
const builtInIntegrations = [
|
|
2228
|
+
acuity,
|
|
2229
|
+
clerk,
|
|
2230
|
+
linear,
|
|
2231
|
+
resend,
|
|
2232
|
+
slack,
|
|
2233
|
+
twilio
|
|
2234
|
+
];
|
|
2235
|
+
//#endregion
|
|
2236
|
+
export { acuity, builtInIntegrations, clerk, linear, resend, slack, twilio };
|