@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/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 };