@devwithbobby/loops 0.1.12 → 0.1.14
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/client/index.d.ts +305 -44
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +21 -32
- package/dist/component/convex.config.d.ts +1 -1
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/convex.config.js +1 -1
- package/dist/component/helpers.d.ts +7 -0
- package/dist/component/helpers.d.ts.map +1 -0
- package/dist/component/helpers.js +30 -0
- package/dist/component/http.d.ts +3 -0
- package/dist/component/http.d.ts.map +1 -0
- package/dist/component/http.js +268 -0
- package/dist/component/lib.d.ts +237 -22
- package/dist/component/lib.d.ts.map +1 -1
- package/dist/component/lib.js +91 -143
- package/dist/component/schema.d.ts +66 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/tables/contacts.d.ts +123 -1
- package/dist/component/tables/contacts.d.ts.map +1 -1
- package/dist/component/tables/emailOperations.d.ts +151 -1
- package/dist/component/tables/emailOperations.d.ts.map +1 -1
- package/dist/component/tables/emailOperations.js +1 -6
- package/dist/component/validators.d.ts +20 -3
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/types.d.ts +97 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/utils.d.ts +186 -3
- package/dist/utils.d.ts.map +1 -1
- package/package.json +101 -101
- package/src/client/index.ts +40 -52
- package/src/component/_generated/api.d.ts +3 -11
- package/src/component/convex.config.ts +7 -2
- package/src/component/helpers.ts +44 -0
- package/src/component/http.ts +304 -0
- package/src/component/lib.ts +189 -204
- package/src/component/tables/contacts.ts +0 -1
- package/src/component/tables/emailOperations.ts +1 -7
- package/src/component/validators.ts +0 -1
- package/src/types.ts +168 -0
- package/src/client/types.ts +0 -64
package/src/client/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { actionGeneric, queryGeneric } from "convex/server";
|
|
2
2
|
import { v } from "convex/values";
|
|
3
|
-
import type { Mounts } from "../component/_generated/api
|
|
4
|
-
import type { RunActionCtx, RunQueryCtx, UseApi } from "
|
|
3
|
+
import type { Mounts } from "../component/_generated/api";
|
|
4
|
+
import type { RunActionCtx, RunQueryCtx, UseApi } from "../types";
|
|
5
5
|
|
|
6
6
|
export type LoopsComponent = UseApi<Mounts>;
|
|
7
7
|
|
|
@@ -18,21 +18,21 @@ export interface ContactData {
|
|
|
18
18
|
export interface TransactionalEmailOptions {
|
|
19
19
|
transactionalId: string;
|
|
20
20
|
email: string;
|
|
21
|
-
dataVariables?: Record<string,
|
|
21
|
+
dataVariables?: Record<string, unknown>;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export interface EventOptions {
|
|
25
25
|
email: string;
|
|
26
26
|
eventName: string;
|
|
27
|
-
eventProperties?: Record<string,
|
|
27
|
+
eventProperties?: Record<string, unknown>;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export class Loops {
|
|
31
|
-
private readonly component: LoopsComponent;
|
|
32
31
|
public readonly options?: {
|
|
33
32
|
apiKey?: string;
|
|
34
33
|
};
|
|
35
|
-
|
|
34
|
+
private readonly lib: NonNullable<LoopsComponent["lib"]>;
|
|
35
|
+
|
|
36
36
|
constructor(
|
|
37
37
|
component: LoopsComponent,
|
|
38
38
|
options?: {
|
|
@@ -43,26 +43,26 @@ export class Loops {
|
|
|
43
43
|
throw new Error(
|
|
44
44
|
"Loops component reference is required. " +
|
|
45
45
|
"Make sure the component is mounted in your convex.config.ts and use: " +
|
|
46
|
-
"new Loops(components.loops)"
|
|
46
|
+
"new Loops(components.loops)",
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
|
|
50
50
|
if (!component.lib) {
|
|
51
51
|
throw new Error(
|
|
52
52
|
"Invalid component reference. " +
|
|
53
53
|
"The component may not be properly mounted. " +
|
|
54
54
|
"Ensure the component is correctly mounted in convex.config.ts: " +
|
|
55
|
-
"app.use(loops);"
|
|
55
|
+
"app.use(loops);",
|
|
56
56
|
);
|
|
57
57
|
}
|
|
58
|
-
|
|
59
|
-
this.
|
|
58
|
+
|
|
59
|
+
this.lib = component.lib;
|
|
60
60
|
this.options = options;
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
const apiKey = options?.apiKey ?? process.env.LOOPS_API_KEY;
|
|
63
63
|
if (!apiKey) {
|
|
64
64
|
throw new Error(
|
|
65
|
-
"Loops API key is required. Set LOOPS_API_KEY in your Convex environment variables."
|
|
65
|
+
"Loops API key is required. Set LOOPS_API_KEY in your Convex environment variables.",
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
|
|
@@ -83,22 +83,7 @@ export class Loops {
|
|
|
83
83
|
* Add or update a contact in Loops
|
|
84
84
|
*/
|
|
85
85
|
async addContact(ctx: RunActionCtx, contact: ContactData) {
|
|
86
|
-
|
|
87
|
-
throw new Error(
|
|
88
|
-
"Loops component is not initialized. " +
|
|
89
|
-
"Make sure to pass components.loops to the Loops constructor: " +
|
|
90
|
-
"new Loops(components.loops)"
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
if (!this.component.lib) {
|
|
94
|
-
throw new Error(
|
|
95
|
-
"Invalid component reference. " +
|
|
96
|
-
"The component may not be properly mounted. " +
|
|
97
|
-
"Ensure the component is correctly mounted in convex.config.ts: " +
|
|
98
|
-
"app.use(loops);"
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
return ctx.runAction((this.component.lib as any).addContact, {
|
|
86
|
+
return ctx.runAction(this.lib.addContact, {
|
|
102
87
|
apiKey: this.apiKey,
|
|
103
88
|
contact,
|
|
104
89
|
});
|
|
@@ -111,10 +96,10 @@ export class Loops {
|
|
|
111
96
|
ctx: RunActionCtx,
|
|
112
97
|
email: string,
|
|
113
98
|
updates: Partial<ContactData> & {
|
|
114
|
-
dataVariables?: Record<string,
|
|
99
|
+
dataVariables?: Record<string, unknown>;
|
|
115
100
|
},
|
|
116
101
|
) {
|
|
117
|
-
return ctx.runAction(
|
|
102
|
+
return ctx.runAction(this.lib.updateContact, {
|
|
118
103
|
apiKey: this.apiKey,
|
|
119
104
|
email,
|
|
120
105
|
...updates,
|
|
@@ -124,8 +109,11 @@ export class Loops {
|
|
|
124
109
|
/**
|
|
125
110
|
* Send a transactional email using a transactional ID
|
|
126
111
|
*/
|
|
127
|
-
async sendTransactional(
|
|
128
|
-
|
|
112
|
+
async sendTransactional(
|
|
113
|
+
ctx: RunActionCtx,
|
|
114
|
+
options: TransactionalEmailOptions,
|
|
115
|
+
) {
|
|
116
|
+
return ctx.runAction(this.lib.sendTransactional, {
|
|
129
117
|
apiKey: this.apiKey,
|
|
130
118
|
...options,
|
|
131
119
|
});
|
|
@@ -135,7 +123,7 @@ export class Loops {
|
|
|
135
123
|
* Send an event to Loops to trigger email workflows
|
|
136
124
|
*/
|
|
137
125
|
async sendEvent(ctx: RunActionCtx, options: EventOptions) {
|
|
138
|
-
return ctx.runAction(
|
|
126
|
+
return ctx.runAction(this.lib.sendEvent, {
|
|
139
127
|
apiKey: this.apiKey,
|
|
140
128
|
...options,
|
|
141
129
|
});
|
|
@@ -146,7 +134,7 @@ export class Loops {
|
|
|
146
134
|
* Retrieves contact information from Loops
|
|
147
135
|
*/
|
|
148
136
|
async findContact(ctx: RunActionCtx, email: string) {
|
|
149
|
-
return ctx.runAction(
|
|
137
|
+
return ctx.runAction(this.lib.findContact, {
|
|
150
138
|
apiKey: this.apiKey,
|
|
151
139
|
email,
|
|
152
140
|
});
|
|
@@ -157,7 +145,7 @@ export class Loops {
|
|
|
157
145
|
* Create multiple contacts in a single API call
|
|
158
146
|
*/
|
|
159
147
|
async batchCreateContacts(ctx: RunActionCtx, contacts: ContactData[]) {
|
|
160
|
-
return ctx.runAction(
|
|
148
|
+
return ctx.runAction(this.lib.batchCreateContacts, {
|
|
161
149
|
apiKey: this.apiKey,
|
|
162
150
|
contacts,
|
|
163
151
|
});
|
|
@@ -168,7 +156,7 @@ export class Loops {
|
|
|
168
156
|
* Unsubscribes a contact from receiving emails (they remain in the system)
|
|
169
157
|
*/
|
|
170
158
|
async unsubscribeContact(ctx: RunActionCtx, email: string) {
|
|
171
|
-
return ctx.runAction(
|
|
159
|
+
return ctx.runAction(this.lib.unsubscribeContact, {
|
|
172
160
|
apiKey: this.apiKey,
|
|
173
161
|
email,
|
|
174
162
|
});
|
|
@@ -179,7 +167,7 @@ export class Loops {
|
|
|
179
167
|
* Resubscribes a previously unsubscribed contact
|
|
180
168
|
*/
|
|
181
169
|
async resubscribeContact(ctx: RunActionCtx, email: string) {
|
|
182
|
-
return ctx.runAction(
|
|
170
|
+
return ctx.runAction(this.lib.resubscribeContact, {
|
|
183
171
|
apiKey: this.apiKey,
|
|
184
172
|
email,
|
|
185
173
|
});
|
|
@@ -198,7 +186,7 @@ export class Loops {
|
|
|
198
186
|
subscribed?: boolean;
|
|
199
187
|
},
|
|
200
188
|
) {
|
|
201
|
-
return ctx.runQuery(
|
|
189
|
+
return ctx.runQuery(this.lib.countContacts, options ?? {});
|
|
202
190
|
}
|
|
203
191
|
|
|
204
192
|
/**
|
|
@@ -216,7 +204,7 @@ export class Loops {
|
|
|
216
204
|
offset?: number;
|
|
217
205
|
},
|
|
218
206
|
) {
|
|
219
|
-
return ctx.runQuery(
|
|
207
|
+
return ctx.runQuery(this.lib.listContacts, {
|
|
220
208
|
userGroup: options?.userGroup,
|
|
221
209
|
source: options?.source,
|
|
222
210
|
subscribed: options?.subscribed,
|
|
@@ -235,7 +223,7 @@ export class Loops {
|
|
|
235
223
|
maxEmailsPerRecipient?: number;
|
|
236
224
|
},
|
|
237
225
|
) {
|
|
238
|
-
return ctx.runQuery(
|
|
226
|
+
return ctx.runQuery(this.lib.detectRecipientSpam, {
|
|
239
227
|
timeWindowMs: options?.timeWindowMs ?? 3600000,
|
|
240
228
|
maxEmailsPerRecipient: options?.maxEmailsPerRecipient ?? 10,
|
|
241
229
|
});
|
|
@@ -251,7 +239,7 @@ export class Loops {
|
|
|
251
239
|
maxEmailsPerActor?: number;
|
|
252
240
|
},
|
|
253
241
|
) {
|
|
254
|
-
return ctx.runQuery(
|
|
242
|
+
return ctx.runQuery(this.lib.detectActorSpam, {
|
|
255
243
|
timeWindowMs: options?.timeWindowMs ?? 3600000,
|
|
256
244
|
maxEmailsPerActor: options?.maxEmailsPerActor ?? 100,
|
|
257
245
|
});
|
|
@@ -266,7 +254,7 @@ export class Loops {
|
|
|
266
254
|
timeWindowMs?: number;
|
|
267
255
|
},
|
|
268
256
|
) {
|
|
269
|
-
return ctx.runQuery(
|
|
257
|
+
return ctx.runQuery(this.lib.getEmailStats, {
|
|
270
258
|
timeWindowMs: options?.timeWindowMs ?? 86400000,
|
|
271
259
|
});
|
|
272
260
|
}
|
|
@@ -281,7 +269,7 @@ export class Loops {
|
|
|
281
269
|
minEmailsInWindow?: number;
|
|
282
270
|
},
|
|
283
271
|
) {
|
|
284
|
-
return ctx.runQuery(
|
|
272
|
+
return ctx.runQuery(this.lib.detectRapidFirePatterns, {
|
|
285
273
|
timeWindowMs: options?.timeWindowMs ?? 60000,
|
|
286
274
|
minEmailsInWindow: options?.minEmailsInWindow ?? 5,
|
|
287
275
|
});
|
|
@@ -298,7 +286,7 @@ export class Loops {
|
|
|
298
286
|
maxEmails: number;
|
|
299
287
|
},
|
|
300
288
|
) {
|
|
301
|
-
return ctx.runQuery(
|
|
289
|
+
return ctx.runQuery(this.lib.checkRecipientRateLimit, options);
|
|
302
290
|
}
|
|
303
291
|
|
|
304
292
|
/**
|
|
@@ -312,7 +300,7 @@ export class Loops {
|
|
|
312
300
|
maxEmails: number;
|
|
313
301
|
},
|
|
314
302
|
) {
|
|
315
|
-
return ctx.runQuery(
|
|
303
|
+
return ctx.runQuery(this.lib.checkActorRateLimit, options);
|
|
316
304
|
}
|
|
317
305
|
|
|
318
306
|
/**
|
|
@@ -325,14 +313,14 @@ export class Loops {
|
|
|
325
313
|
maxEmails: number;
|
|
326
314
|
},
|
|
327
315
|
) {
|
|
328
|
-
return ctx.runQuery(
|
|
316
|
+
return ctx.runQuery(this.lib.checkGlobalRateLimit, options);
|
|
329
317
|
}
|
|
330
318
|
|
|
331
319
|
/**
|
|
332
320
|
* Delete a contact from Loops
|
|
333
321
|
*/
|
|
334
322
|
async deleteContact(ctx: RunActionCtx, email: string) {
|
|
335
|
-
return ctx.runAction(
|
|
323
|
+
return ctx.runAction(this.lib.deleteContact, {
|
|
336
324
|
apiKey: this.apiKey,
|
|
337
325
|
email,
|
|
338
326
|
});
|
|
@@ -341,11 +329,11 @@ export class Loops {
|
|
|
341
329
|
/**
|
|
342
330
|
* Trigger a loop for a contact
|
|
343
331
|
* Loops are automated email sequences that can be triggered by events
|
|
344
|
-
*
|
|
332
|
+
*
|
|
345
333
|
* Note: Loops.so doesn't have a direct loop trigger endpoint.
|
|
346
334
|
* Loops are triggered through events. Make sure your loop is configured
|
|
347
335
|
* in the Loops dashboard to listen for events.
|
|
348
|
-
*
|
|
336
|
+
*
|
|
349
337
|
* @param options.eventName - Optional event name. If not provided, uses `loop_{loopId}`
|
|
350
338
|
*/
|
|
351
339
|
async triggerLoop(
|
|
@@ -353,11 +341,11 @@ export class Loops {
|
|
|
353
341
|
options: {
|
|
354
342
|
loopId: string;
|
|
355
343
|
email: string;
|
|
356
|
-
dataVariables?: Record<string,
|
|
344
|
+
dataVariables?: Record<string, unknown>;
|
|
357
345
|
eventName?: string; // Event name that triggers the loop
|
|
358
346
|
},
|
|
359
347
|
) {
|
|
360
|
-
return ctx.runAction(
|
|
348
|
+
return ctx.runAction(this.lib.triggerLoop, {
|
|
361
349
|
apiKey: this.apiKey,
|
|
362
350
|
...options,
|
|
363
351
|
});
|
|
@@ -27,17 +27,9 @@ import type {
|
|
|
27
27
|
declare const fullApi: ApiFromModules<{
|
|
28
28
|
lib: typeof lib;
|
|
29
29
|
}>;
|
|
30
|
-
export type Mounts = {
|
|
31
|
-
lib:
|
|
32
|
-
|
|
33
|
-
"mutation",
|
|
34
|
-
"public",
|
|
35
|
-
{ count: number; name: string; shards?: number },
|
|
36
|
-
null
|
|
37
|
-
>;
|
|
38
|
-
count: FunctionReference<"query", "public", { name: string }, number>;
|
|
39
|
-
};
|
|
40
|
-
};
|
|
30
|
+
export type Mounts = ApiFromModules<{
|
|
31
|
+
lib: typeof lib;
|
|
32
|
+
}>;
|
|
41
33
|
// For now fullApiWithMounts is only fullApi which provides
|
|
42
34
|
// jump-to-definition in component client code.
|
|
43
35
|
// Use Mounts for the same type without the inference.
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { defineComponent } from "convex/server";
|
|
2
|
-
import { api } from "./_generated/api
|
|
2
|
+
import { api } from "./_generated/api";
|
|
3
3
|
|
|
4
4
|
const component = defineComponent("loops");
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
type ExportableComponent = typeof component & {
|
|
7
|
+
export: (apiRef: typeof api, functions: Record<string, unknown>) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
(component as ExportableComponent).export(api, {
|
|
6
11
|
addContact: api.lib.addContact,
|
|
7
12
|
updateContact: api.lib.updateContact,
|
|
8
13
|
findContact: api.lib.findContact,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const LOOPS_API_BASE_URL = "https://app.loops.so/api/v1";
|
|
2
|
+
|
|
3
|
+
export const sanitizeLoopsError = (
|
|
4
|
+
status: number,
|
|
5
|
+
_errorText: string,
|
|
6
|
+
): Error => {
|
|
7
|
+
if (status === 401 || status === 403) {
|
|
8
|
+
return new Error("Authentication failed. Please check your API key.");
|
|
9
|
+
}
|
|
10
|
+
if (status === 404) {
|
|
11
|
+
return new Error("Resource not found.");
|
|
12
|
+
}
|
|
13
|
+
if (status === 429) {
|
|
14
|
+
return new Error("Rate limit exceeded. Please try again later.");
|
|
15
|
+
}
|
|
16
|
+
if (status >= 500) {
|
|
17
|
+
return new Error("Loops service error. Please try again later.");
|
|
18
|
+
}
|
|
19
|
+
return new Error(`Loops API error (${status}). Please try again.`);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type LoopsRequestInit = Omit<RequestInit, "body"> & {
|
|
23
|
+
json?: unknown;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const loopsFetch = async (
|
|
27
|
+
apiKey: string,
|
|
28
|
+
path: string,
|
|
29
|
+
init: LoopsRequestInit = {},
|
|
30
|
+
) => {
|
|
31
|
+
const { json, ...rest } = init;
|
|
32
|
+
const headers = new Headers(rest.headers ?? {});
|
|
33
|
+
headers.set("Authorization", `Bearer ${apiKey}`);
|
|
34
|
+
if (json !== undefined && !headers.has("Content-Type")) {
|
|
35
|
+
headers.set("Content-Type", "application/json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return fetch(`${LOOPS_API_BASE_URL}${path}`, {
|
|
39
|
+
...rest,
|
|
40
|
+
headers,
|
|
41
|
+
// @ts-expect-error RequestInit in this build doesn't declare body
|
|
42
|
+
body: json !== undefined ? JSON.stringify(json) : rest.body,
|
|
43
|
+
});
|
|
44
|
+
};
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { httpRouter } from "convex/server";
|
|
2
|
+
import {
|
|
3
|
+
type ContactPayload,
|
|
4
|
+
type DeleteContactPayload,
|
|
5
|
+
type EventPayload,
|
|
6
|
+
type HeadersInitParam,
|
|
7
|
+
internalLib,
|
|
8
|
+
type TransactionalPayload,
|
|
9
|
+
type TriggerPayload,
|
|
10
|
+
type UpdateContactPayload,
|
|
11
|
+
} from "../types";
|
|
12
|
+
import { httpAction } from "./_generated/server";
|
|
13
|
+
|
|
14
|
+
const http = httpRouter();
|
|
15
|
+
|
|
16
|
+
const allowedOrigin =
|
|
17
|
+
process.env.LOOPS_HTTP_ALLOWED_ORIGIN ?? process.env.CLIENT_ORIGIN ?? "*";
|
|
18
|
+
|
|
19
|
+
const buildCorsHeaders = (extra?: HeadersInitParam) => {
|
|
20
|
+
const headers = new Headers(extra ?? {});
|
|
21
|
+
headers.set("Access-Control-Allow-Origin", allowedOrigin);
|
|
22
|
+
headers.set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
|
|
23
|
+
headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
24
|
+
headers.set("Access-Control-Max-Age", "86400");
|
|
25
|
+
headers.set("Vary", "Origin");
|
|
26
|
+
return headers;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const jsonResponse = (data: unknown, init?: ResponseInit) => {
|
|
30
|
+
const headers = buildCorsHeaders(
|
|
31
|
+
(init?.headers as HeadersInitParam | undefined) ?? undefined,
|
|
32
|
+
);
|
|
33
|
+
headers.set("Content-Type", "application/json");
|
|
34
|
+
return new Response(JSON.stringify(data), { ...init, headers });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const emptyResponse = (init?: ResponseInit) => {
|
|
38
|
+
const headers = buildCorsHeaders(
|
|
39
|
+
(init?.headers as HeadersInitParam | undefined) ?? undefined,
|
|
40
|
+
);
|
|
41
|
+
return new Response(null, { ...init, headers });
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const readJsonBody = async <T>(request: Request): Promise<T> => {
|
|
45
|
+
try {
|
|
46
|
+
return (await request.json()) as T;
|
|
47
|
+
} catch (_error) {
|
|
48
|
+
throw new Error("Invalid JSON body");
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const booleanFromQuery = (value: string | null) => {
|
|
53
|
+
if (value === null) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
if (value === "true") {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
if (value === "false") {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const numberFromQuery = (value: string | null, fallback: number) => {
|
|
66
|
+
if (!value) {
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
69
|
+
const parsed = Number.parseInt(value, 10);
|
|
70
|
+
return Number.isNaN(parsed) ? fallback : parsed;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const requireLoopsApiKey = () => {
|
|
74
|
+
const apiKey = process.env.LOOPS_API_KEY;
|
|
75
|
+
if (!apiKey) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
"LOOPS_API_KEY environment variable must be set to use the HTTP API.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return apiKey;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const respondError = (error: unknown) => {
|
|
84
|
+
console.error("[loops:http]", error);
|
|
85
|
+
const message = error instanceof Error ? error.message : "Unexpected error";
|
|
86
|
+
const status =
|
|
87
|
+
error instanceof Error &&
|
|
88
|
+
error.message.includes("LOOPS_API_KEY environment variable")
|
|
89
|
+
? 500
|
|
90
|
+
: 400;
|
|
91
|
+
return jsonResponse({ error: message }, { status });
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
http.route({
|
|
95
|
+
pathPrefix: "/loops",
|
|
96
|
+
method: "OPTIONS",
|
|
97
|
+
handler: httpAction(async (_ctx, request) => {
|
|
98
|
+
const headers = buildCorsHeaders();
|
|
99
|
+
const requestedHeaders = request.headers.get(
|
|
100
|
+
"Access-Control-Request-Headers",
|
|
101
|
+
);
|
|
102
|
+
if (requestedHeaders) {
|
|
103
|
+
headers.set("Access-Control-Allow-Headers", requestedHeaders);
|
|
104
|
+
}
|
|
105
|
+
const requestedMethod = request.headers.get(
|
|
106
|
+
"Access-Control-Request-Method",
|
|
107
|
+
);
|
|
108
|
+
if (requestedMethod) {
|
|
109
|
+
headers.set("Access-Control-Allow-Methods", `${requestedMethod},OPTIONS`);
|
|
110
|
+
}
|
|
111
|
+
return new Response(null, { status: 204, headers });
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
http.route({
|
|
116
|
+
path: "/loops/contacts",
|
|
117
|
+
method: "POST",
|
|
118
|
+
handler: httpAction(async (ctx, request) => {
|
|
119
|
+
try {
|
|
120
|
+
const contact = await readJsonBody<ContactPayload>(request);
|
|
121
|
+
const data = await ctx.runAction(internalLib.addContact, {
|
|
122
|
+
apiKey: requireLoopsApiKey(),
|
|
123
|
+
contact,
|
|
124
|
+
});
|
|
125
|
+
return jsonResponse(data, { status: 201 });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return respondError(error);
|
|
128
|
+
}
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
http.route({
|
|
133
|
+
path: "/loops/contacts",
|
|
134
|
+
method: "PUT",
|
|
135
|
+
handler: httpAction(async (ctx, request) => {
|
|
136
|
+
try {
|
|
137
|
+
const payload = await readJsonBody<UpdateContactPayload>(request);
|
|
138
|
+
if (!payload.email) {
|
|
139
|
+
throw new Error("email is required");
|
|
140
|
+
}
|
|
141
|
+
const data = await ctx.runAction(internalLib.updateContact, {
|
|
142
|
+
apiKey: requireLoopsApiKey(),
|
|
143
|
+
email: payload.email,
|
|
144
|
+
dataVariables: payload.dataVariables,
|
|
145
|
+
firstName: payload.firstName,
|
|
146
|
+
lastName: payload.lastName,
|
|
147
|
+
userId: payload.userId,
|
|
148
|
+
source: payload.source,
|
|
149
|
+
subscribed: payload.subscribed,
|
|
150
|
+
userGroup: payload.userGroup,
|
|
151
|
+
});
|
|
152
|
+
return jsonResponse(data);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return respondError(error);
|
|
155
|
+
}
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
http.route({
|
|
160
|
+
path: "/loops/contacts",
|
|
161
|
+
method: "GET",
|
|
162
|
+
handler: httpAction(async (ctx, request) => {
|
|
163
|
+
try {
|
|
164
|
+
const url = new URL(request.url);
|
|
165
|
+
const email = url.searchParams.get("email");
|
|
166
|
+
if (email) {
|
|
167
|
+
const data = await ctx.runAction(internalLib.findContact, {
|
|
168
|
+
apiKey: requireLoopsApiKey(),
|
|
169
|
+
email,
|
|
170
|
+
});
|
|
171
|
+
return jsonResponse(data);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const data = await ctx.runQuery(internalLib.listContacts, {
|
|
175
|
+
userGroup: url.searchParams.get("userGroup") ?? undefined,
|
|
176
|
+
source: url.searchParams.get("source") ?? undefined,
|
|
177
|
+
subscribed: booleanFromQuery(url.searchParams.get("subscribed")),
|
|
178
|
+
limit: numberFromQuery(url.searchParams.get("limit"), 100),
|
|
179
|
+
offset: numberFromQuery(url.searchParams.get("offset"), 0),
|
|
180
|
+
});
|
|
181
|
+
return jsonResponse(data);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return respondError(error);
|
|
184
|
+
}
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
http.route({
|
|
189
|
+
path: "/loops/contacts",
|
|
190
|
+
method: "DELETE",
|
|
191
|
+
handler: httpAction(async (ctx, request) => {
|
|
192
|
+
try {
|
|
193
|
+
const payload = await readJsonBody<DeleteContactPayload>(request);
|
|
194
|
+
if (!payload.email) {
|
|
195
|
+
throw new Error("email is required");
|
|
196
|
+
}
|
|
197
|
+
await ctx.runAction(internalLib.deleteContact, {
|
|
198
|
+
apiKey: requireLoopsApiKey(),
|
|
199
|
+
email: payload.email,
|
|
200
|
+
});
|
|
201
|
+
return emptyResponse({ status: 204 });
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return respondError(error);
|
|
204
|
+
}
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
http.route({
|
|
209
|
+
path: "/loops/transactional",
|
|
210
|
+
method: "POST",
|
|
211
|
+
handler: httpAction(async (ctx, request) => {
|
|
212
|
+
try {
|
|
213
|
+
const payload = await readJsonBody<TransactionalPayload>(request);
|
|
214
|
+
if (!payload.transactionalId) {
|
|
215
|
+
throw new Error("transactionalId is required");
|
|
216
|
+
}
|
|
217
|
+
if (!payload.email) {
|
|
218
|
+
throw new Error("email is required");
|
|
219
|
+
}
|
|
220
|
+
const data = await ctx.runAction(internalLib.sendTransactional, {
|
|
221
|
+
apiKey: requireLoopsApiKey(),
|
|
222
|
+
transactionalId: payload.transactionalId,
|
|
223
|
+
email: payload.email,
|
|
224
|
+
dataVariables: payload.dataVariables,
|
|
225
|
+
});
|
|
226
|
+
return jsonResponse(data);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return respondError(error);
|
|
229
|
+
}
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
http.route({
|
|
234
|
+
path: "/loops/events",
|
|
235
|
+
method: "POST",
|
|
236
|
+
handler: httpAction(async (ctx, request) => {
|
|
237
|
+
try {
|
|
238
|
+
const payload = await readJsonBody<EventPayload>(request);
|
|
239
|
+
if (!payload.email) {
|
|
240
|
+
throw new Error("email is required");
|
|
241
|
+
}
|
|
242
|
+
if (!payload.eventName) {
|
|
243
|
+
throw new Error("eventName is required");
|
|
244
|
+
}
|
|
245
|
+
const data = await ctx.runAction(internalLib.sendEvent, {
|
|
246
|
+
apiKey: requireLoopsApiKey(),
|
|
247
|
+
email: payload.email,
|
|
248
|
+
eventName: payload.eventName,
|
|
249
|
+
eventProperties: payload.eventProperties,
|
|
250
|
+
});
|
|
251
|
+
return jsonResponse(data);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return respondError(error);
|
|
254
|
+
}
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
http.route({
|
|
259
|
+
path: "/loops/trigger",
|
|
260
|
+
method: "POST",
|
|
261
|
+
handler: httpAction(async (ctx, request) => {
|
|
262
|
+
try {
|
|
263
|
+
const payload = await readJsonBody<TriggerPayload>(request);
|
|
264
|
+
if (!payload.loopId) {
|
|
265
|
+
throw new Error("loopId is required");
|
|
266
|
+
}
|
|
267
|
+
if (!payload.email) {
|
|
268
|
+
throw new Error("email is required");
|
|
269
|
+
}
|
|
270
|
+
const data = await ctx.runAction(internalLib.triggerLoop, {
|
|
271
|
+
apiKey: requireLoopsApiKey(),
|
|
272
|
+
loopId: payload.loopId,
|
|
273
|
+
email: payload.email,
|
|
274
|
+
dataVariables: payload.dataVariables,
|
|
275
|
+
eventName: payload.eventName,
|
|
276
|
+
});
|
|
277
|
+
return jsonResponse(data);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
return respondError(error);
|
|
280
|
+
}
|
|
281
|
+
}),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
http.route({
|
|
285
|
+
path: "/loops/stats",
|
|
286
|
+
method: "GET",
|
|
287
|
+
handler: httpAction(async (ctx, request) => {
|
|
288
|
+
try {
|
|
289
|
+
const url = new URL(request.url);
|
|
290
|
+
const timeWindowMs = numberFromQuery(
|
|
291
|
+
url.searchParams.get("timeWindowMs"),
|
|
292
|
+
86400000,
|
|
293
|
+
);
|
|
294
|
+
const data = await ctx.runQuery(internalLib.getEmailStats, {
|
|
295
|
+
timeWindowMs,
|
|
296
|
+
});
|
|
297
|
+
return jsonResponse(data);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
return respondError(error);
|
|
300
|
+
}
|
|
301
|
+
}),
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
export default http;
|