@devwithbobby/loops 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -124,8 +124,7 @@ await loops.addContact(ctx, {
124
124
  #### Update Contact
125
125
 
126
126
  ```typescript
127
- await loops.updateContact(ctx, {
128
- email: "user@example.com",
127
+ await loops.updateContact(ctx, "user@example.com", {
129
128
  firstName: "Jane",
130
129
  userGroup: "vip",
131
130
  });
@@ -134,17 +133,13 @@ await loops.updateContact(ctx, {
134
133
  #### Find Contact
135
134
 
136
135
  ```typescript
137
- const contact = await loops.findContact(ctx, {
138
- email: "user@example.com",
139
- });
136
+ const contact = await loops.findContact(ctx, "user@example.com");
140
137
  ```
141
138
 
142
139
  #### Delete Contact
143
140
 
144
141
  ```typescript
145
- await loops.deleteContact(ctx, {
146
- email: "user@example.com",
147
- });
142
+ await loops.deleteContact(ctx, "user@example.com");
148
143
  ```
149
144
 
150
145
  #### Batch Create Contacts
@@ -161,8 +156,8 @@ await loops.batchCreateContacts(ctx, {
161
156
  #### Unsubscribe/Resubscribe
162
157
 
163
158
  ```typescript
164
- await loops.unsubscribeContact(ctx, { email: "user@example.com" });
165
- await loops.resubscribeContact(ctx, { email: "user@example.com" });
159
+ await loops.unsubscribeContact(ctx, "user@example.com");
160
+ await loops.resubscribeContact(ctx, "user@example.com");
166
161
  ```
167
162
 
168
163
  #### Count Contacts
@@ -383,33 +378,59 @@ export const addContact = action({
383
378
  ## Security Best Practices
384
379
 
385
380
  1. **Always add authentication** - Wrap all functions with auth checks
386
- 2. **Use environment variables** - Store API key in Convex environment variables
387
- 3. **Implement rate limiting** - Use the built-in rate limiting queries
381
+ 2. **Use environment variables** - Store API key in Convex environment variables (never hardcode)
382
+ 3. **Implement rate limiting** - Use the built-in rate limiting queries to prevent abuse
388
383
  4. **Monitor for abuse** - Use spam detection queries to identify suspicious patterns
389
384
  5. **Sanitize errors** - Don't expose sensitive error details to clients
390
385
 
391
- See [SECURITY.md](./prds/SECURITY.md) for detailed security guidelines.
392
-
393
- ## Monitoring & Rate Limiting
394
-
395
- The component automatically logs all email operations for monitoring. Use the built-in queries to:
386
+ ### Authentication Example
396
387
 
397
- - Track email statistics
398
- - Detect spam patterns
399
- - Enforce rate limits
400
- - Monitor for abuse
388
+ All functions should be wrapped with authentication:
401
389
 
402
- See [MONITORING.md](./prds/MONITORING.md) and [RATE_LIMITING.md](./prds/RATE_LIMITING.md) for detailed guides.
390
+ ```typescript
391
+ export const addContact = action({
392
+ args: { email: v.string(), ... },
393
+ handler: async (ctx, args) => {
394
+ // Add authentication check
395
+ const identity = await ctx.auth.getUserIdentity();
396
+ if (!identity) throw new Error("Unauthorized");
397
+
398
+ // Add authorization checks if needed
399
+ // if (!isAdmin(identity)) throw new Error("Forbidden");
400
+
401
+ return await loops.addContact(ctx, args);
402
+ },
403
+ });
404
+ ```
403
405
 
404
- ## Environment Variables
406
+ ### Environment Variables
405
407
 
406
408
  Set `LOOPS_API_KEY` in your Convex environment:
407
409
 
410
+ **Via CLI:**
408
411
  ```bash
409
412
  npx convex env set LOOPS_API_KEY "your-api-key"
410
413
  ```
411
414
 
412
- See [ENV_SETUP.md](./prds/ENV_SETUP.md) for detailed setup instructions.
415
+ **Via Dashboard:**
416
+ 1. Go to your Convex Dashboard
417
+ 2. Navigate to Settings → Environment Variables
418
+ 3. Add `LOOPS_API_KEY` with your Loops.so API key value
419
+
420
+ Get your API key from [Loops.so Dashboard](https://app.loops.so/settings/api).
421
+
422
+ ⚠️ **Never** pass the API key directly in code or via function options in production. Always use environment variables.
423
+
424
+ ## Monitoring & Rate Limiting
425
+
426
+ The component automatically logs all email operations to the `emailOperations` table for monitoring. Use the built-in queries to:
427
+
428
+ - **Track email statistics** - See total sends, success/failure rates, breakdowns by type
429
+ - **Detect spam patterns** - Identify suspicious activity by recipient or actor
430
+ - **Enforce rate limits** - Prevent abuse with recipient, actor, or global rate limits
431
+ - **Monitor for abuse** - Detect rapid-fire patterns and unusual sending behavior
432
+
433
+ All monitoring queries are available through the `Loops` client - see the [Monitoring & Analytics](#monitoring--analytics) section above for usage examples.
413
434
 
414
435
  ## Development
415
436
 
@@ -17,6 +17,8 @@ export declare const logEmailOperation: any;
17
17
  export declare const countContacts: any;
18
18
  /**
19
19
  * Add or update a contact in Loops
20
+ * This function tries to create a contact, and if the email already exists (409),
21
+ * it falls back to updating the contact instead.
20
22
  */
21
23
  export declare const addContact: any;
22
24
  /**
@@ -49,6 +51,7 @@ export declare const triggerLoop: any;
49
51
  /**
50
52
  * Find a contact by email
51
53
  * Retrieves contact information from Loops
54
+ * Note: Loops API may return either an object or an array
52
55
  */
53
56
  export declare const findContact: any;
54
57
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../../src/component/lib.ts"],"names":[],"mappings":"AA0BA;;GAEG;AACH,eAAO,MAAM,YAAY,KA6CvB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa,KAexB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,KAgC5B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,aAAa,KAwCxB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,UAAU,KA2CrB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa,KAoDxB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,KAqD5B,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,SAAS,KAgCpB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa,KA8BxB,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,YAAY,KAkFvB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,WAAW,KA+CtB,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,WAAW,KAiDtB,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,mBAAmB,KA4C9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,KA+B7B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,KA+B7B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,mBAAmB,KAwC9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,eAAe,KAwC1B,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa,KAgDxB,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,uBAAuB,KAsGlC,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,uBAAuB,KA6ClC,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,mBAAmB,KA6C9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,oBAAoB,KA8B/B,CAAC"}
1
+ {"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../../src/component/lib.ts"],"names":[],"mappings":"AA0BA;;GAEG;AACH,eAAO,MAAM,YAAY,KA6CvB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa,KAexB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,KAgC5B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,aAAa,KAwCxB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,UAAU,KAiHrB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa,KAoDxB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,KAqD5B,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,SAAS,KAgCpB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa,KA8BxB,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,YAAY,KAkFvB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,WAAW,KA+CtB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,WAAW,KAoDtB,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,mBAAmB,KA4C9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,KA+B7B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,KA+B7B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,mBAAmB,KAwC9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,eAAe,KAwC1B,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa,KAgDxB,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,uBAAuB,KAsGlC,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,uBAAuB,KA6ClC,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,mBAAmB,KA6C9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,oBAAoB,KA8B/B,CAAC"}
@@ -177,6 +177,8 @@ export const countContacts = zq({
177
177
  });
178
178
  /**
179
179
  * Add or update a contact in Loops
180
+ * This function tries to create a contact, and if the email already exists (409),
181
+ * it falls back to updating the contact instead.
180
182
  */
181
183
  export const addContact = za({
182
184
  args: z.object({
@@ -198,9 +200,67 @@ export const addContact = za({
198
200
  });
199
201
  if (!response.ok) {
200
202
  const errorText = await response.text();
203
+ if (response.status === 409) {
204
+ console.log(`Contact ${args.contact.email} already exists, updating instead`);
205
+ const findResponse = await fetch(`${LOOPS_API_BASE_URL}/contacts/find?email=${encodeURIComponent(args.contact.email)}`, {
206
+ method: "GET",
207
+ headers: {
208
+ Authorization: `Bearer ${args.apiKey}`,
209
+ "Content-Type": "application/json",
210
+ },
211
+ });
212
+ if (!findResponse.ok) {
213
+ const findErrorText = await findResponse.text();
214
+ console.error(`Failed to find existing contact [${findResponse.status}]:`, findErrorText);
215
+ }
216
+ const updateResponse = await fetch(`${LOOPS_API_BASE_URL}/contacts/update`, {
217
+ method: "PUT",
218
+ headers: {
219
+ Authorization: `Bearer ${args.apiKey}`,
220
+ "Content-Type": "application/json",
221
+ },
222
+ body: JSON.stringify({
223
+ email: args.contact.email,
224
+ firstName: args.contact.firstName,
225
+ lastName: args.contact.lastName,
226
+ userId: args.contact.userId,
227
+ source: args.contact.source,
228
+ subscribed: args.contact.subscribed,
229
+ userGroup: args.contact.userGroup,
230
+ }),
231
+ });
232
+ if (!updateResponse.ok) {
233
+ const updateErrorText = await updateResponse.text();
234
+ console.error(`Loops API error [${updateResponse.status}]:`, updateErrorText);
235
+ throw sanitizeError(updateResponse.status, updateErrorText);
236
+ }
237
+ // Get contact ID if available
238
+ let contactId;
239
+ if (findResponse.ok) {
240
+ const findData = (await findResponse.json());
241
+ contactId = findData.id;
242
+ }
243
+ // Store/update in our database
244
+ await ctx.runMutation((internal.lib).storeContact, {
245
+ email: args.contact.email,
246
+ firstName: args.contact.firstName,
247
+ lastName: args.contact.lastName,
248
+ userId: args.contact.userId,
249
+ source: args.contact.source,
250
+ subscribed: args.contact.subscribed,
251
+ userGroup: args.contact.userGroup,
252
+ loopsContactId: contactId,
253
+ });
254
+ return {
255
+ success: true,
256
+ id: contactId,
257
+ };
258
+ }
259
+ // For other errors, throw as normal
201
260
  console.error(`Loops API error [${response.status}]:`, errorText);
202
261
  throw sanitizeError(response.status, errorText);
203
262
  }
263
+ // Contact was created successfully
204
264
  const data = (await response.json());
205
265
  await ctx.runMutation((internal.lib).storeContact, {
206
266
  email: args.contact.email,
@@ -518,6 +578,7 @@ export const triggerLoop = za({
518
578
  /**
519
579
  * Find a contact by email
520
580
  * Retrieves contact information from Loops
581
+ * Note: Loops API may return either an object or an array
521
582
  */
522
583
  export const findContact = za({
523
584
  args: z.object({
@@ -557,9 +618,11 @@ export const findContact = za({
557
618
  throw sanitizeError(response.status, errorText);
558
619
  }
559
620
  const data = (await response.json());
621
+ // Handle case where Loops returns an array instead of a single object
622
+ const contact = Array.isArray(data) ? data[0] : data;
560
623
  return {
561
624
  success: true,
562
- contact: data,
625
+ contact: contact,
563
626
  };
564
627
  },
565
628
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devwithbobby/loops",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Convex component for integrating with Loops.so email marketing platform",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -179,6 +179,8 @@ export const countContacts = zq({
179
179
 
180
180
  /**
181
181
  * Add or update a contact in Loops
182
+ * This function tries to create a contact, and if the email already exists (409),
183
+ * it falls back to updating the contact instead.
182
184
  */
183
185
  export const addContact = za({
184
186
  args: z.object({
@@ -201,10 +203,80 @@ export const addContact = za({
201
203
 
202
204
  if (!response.ok) {
203
205
  const errorText = await response.text();
206
+
207
+ if (response.status === 409) {
208
+ console.log(`Contact ${args.contact.email} already exists, updating instead`);
209
+
210
+ const findResponse = await fetch(
211
+ `${LOOPS_API_BASE_URL}/contacts/find?email=${encodeURIComponent(args.contact.email)}`,
212
+ {
213
+ method: "GET",
214
+ headers: {
215
+ Authorization: `Bearer ${args.apiKey}`,
216
+ "Content-Type": "application/json",
217
+ },
218
+ }
219
+ );
220
+
221
+ if (!findResponse.ok) {
222
+ const findErrorText = await findResponse.text();
223
+ console.error(`Failed to find existing contact [${findResponse.status}]:`, findErrorText);
224
+ }
225
+
226
+ const updateResponse = await fetch(`${LOOPS_API_BASE_URL}/contacts/update`, {
227
+ method: "PUT",
228
+ headers: {
229
+ Authorization: `Bearer ${args.apiKey}`,
230
+ "Content-Type": "application/json",
231
+ },
232
+ body: JSON.stringify({
233
+ email: args.contact.email,
234
+ firstName: args.contact.firstName,
235
+ lastName: args.contact.lastName,
236
+ userId: args.contact.userId,
237
+ source: args.contact.source,
238
+ subscribed: args.contact.subscribed,
239
+ userGroup: args.contact.userGroup,
240
+ }),
241
+ });
242
+
243
+ if (!updateResponse.ok) {
244
+ const updateErrorText = await updateResponse.text();
245
+ console.error(`Loops API error [${updateResponse.status}]:`, updateErrorText);
246
+ throw sanitizeError(updateResponse.status, updateErrorText);
247
+ }
248
+
249
+ // Get contact ID if available
250
+ let contactId: string | undefined;
251
+ if (findResponse.ok) {
252
+ const findData = (await findResponse.json()) as { id?: string };
253
+ contactId = findData.id;
254
+ }
255
+
256
+ // Store/update in our database
257
+ await ctx.runMutation(((internal as any).lib).storeContact as any, {
258
+ email: args.contact.email,
259
+ firstName: args.contact.firstName,
260
+ lastName: args.contact.lastName,
261
+ userId: args.contact.userId,
262
+ source: args.contact.source,
263
+ subscribed: args.contact.subscribed,
264
+ userGroup: args.contact.userGroup,
265
+ loopsContactId: contactId,
266
+ });
267
+
268
+ return {
269
+ success: true,
270
+ id: contactId,
271
+ };
272
+ }
273
+
274
+ // For other errors, throw as normal
204
275
  console.error(`Loops API error [${response.status}]:`, errorText);
205
276
  throw sanitizeError(response.status, errorText);
206
277
  }
207
278
 
279
+ // Contact was created successfully
208
280
  const data = (await response.json()) as { id?: string };
209
281
 
210
282
  await ctx.runMutation(((internal as any).lib).storeContact as any, {
@@ -557,6 +629,7 @@ export const triggerLoop = za({
557
629
  /**
558
630
  * Find a contact by email
559
631
  * Retrieves contact information from Loops
632
+ * Note: Loops API may return either an object or an array
560
633
  */
561
634
  export const findContact = za({
562
635
  args: z.object({
@@ -600,11 +673,14 @@ export const findContact = za({
600
673
  throw sanitizeError(response.status, errorText);
601
674
  }
602
675
 
603
- const data = (await response.json()) as Record<string, any>;
676
+ const data = (await response.json()) as Record<string, any> | Array<Record<string, any>>;
677
+
678
+ // Handle case where Loops returns an array instead of a single object
679
+ const contact = Array.isArray(data) ? data[0] : data;
604
680
 
605
681
  return {
606
682
  success: true,
607
- contact: data,
683
+ contact: contact as Record<string, any> | undefined,
608
684
  };
609
685
  },
610
686
  });