@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.
Files changed (41) hide show
  1. package/dist/client/index.d.ts +305 -44
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +21 -32
  4. package/dist/component/convex.config.d.ts +1 -1
  5. package/dist/component/convex.config.d.ts.map +1 -1
  6. package/dist/component/convex.config.js +1 -1
  7. package/dist/component/helpers.d.ts +7 -0
  8. package/dist/component/helpers.d.ts.map +1 -0
  9. package/dist/component/helpers.js +30 -0
  10. package/dist/component/http.d.ts +3 -0
  11. package/dist/component/http.d.ts.map +1 -0
  12. package/dist/component/http.js +268 -0
  13. package/dist/component/lib.d.ts +237 -22
  14. package/dist/component/lib.d.ts.map +1 -1
  15. package/dist/component/lib.js +91 -143
  16. package/dist/component/schema.d.ts +66 -1
  17. package/dist/component/schema.d.ts.map +1 -1
  18. package/dist/component/tables/contacts.d.ts +123 -1
  19. package/dist/component/tables/contacts.d.ts.map +1 -1
  20. package/dist/component/tables/emailOperations.d.ts +151 -1
  21. package/dist/component/tables/emailOperations.d.ts.map +1 -1
  22. package/dist/component/tables/emailOperations.js +1 -6
  23. package/dist/component/validators.d.ts +20 -3
  24. package/dist/component/validators.d.ts.map +1 -1
  25. package/dist/types.d.ts +97 -0
  26. package/dist/types.d.ts.map +1 -0
  27. package/dist/types.js +2 -0
  28. package/dist/utils.d.ts +186 -3
  29. package/dist/utils.d.ts.map +1 -1
  30. package/package.json +101 -101
  31. package/src/client/index.ts +40 -52
  32. package/src/component/_generated/api.d.ts +3 -11
  33. package/src/component/convex.config.ts +7 -2
  34. package/src/component/helpers.ts +44 -0
  35. package/src/component/http.ts +304 -0
  36. package/src/component/lib.ts +189 -204
  37. package/src/component/tables/contacts.ts +0 -1
  38. package/src/component/tables/emailOperations.ts +1 -7
  39. package/src/component/validators.ts +0 -1
  40. package/src/types.ts +168 -0
  41. package/src/client/types.ts +0 -64
@@ -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.js";
4
- import type { RunActionCtx, RunQueryCtx, UseApi } from "./types.js";
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, any>;
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, any>;
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.component = component;
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
- if (!this.component) {
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, any>;
99
+ dataVariables?: Record<string, unknown>;
115
100
  },
116
101
  ) {
117
- return ctx.runAction((this.component.lib as any).updateContact, {
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(ctx: RunActionCtx, options: TransactionalEmailOptions) {
128
- return ctx.runAction((this.component.lib as any).sendTransactional, {
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((this.component.lib as any).sendEvent, {
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((this.component.lib as any).findContact, {
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((this.component.lib as any).batchCreateContacts, {
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((this.component.lib as any).unsubscribeContact, {
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((this.component.lib as any).resubscribeContact, {
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((this.component.lib as any).countContacts, options ?? {});
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((this.component.lib as any).listContacts, {
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((this.component.lib as any).detectRecipientSpam, {
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((this.component.lib as any).detectActorSpam, {
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((this.component.lib as any).getEmailStats, {
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((this.component.lib as any).detectRapidFirePatterns, {
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((this.component.lib as any).checkRecipientRateLimit, options);
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((this.component.lib as any).checkActorRateLimit, options);
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((this.component.lib as any).checkGlobalRateLimit, options);
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((this.component.lib as any).deleteContact, {
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, any>;
344
+ dataVariables?: Record<string, unknown>;
357
345
  eventName?: string; // Event name that triggers the loop
358
346
  },
359
347
  ) {
360
- return ctx.runAction((this.component.lib as any).triggerLoop, {
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
- add: FunctionReference<
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.js";
2
+ import { api } from "./_generated/api";
3
3
 
4
4
  const component = defineComponent("loops");
5
- (component as any).export(api, {
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;