@devwithbobby/loops 0.1.12 → 0.1.13

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.
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { za, zm, zq } from "../utils.js";
3
3
  import { internal } from "./_generated/api";
4
+ import type { Doc } from "./_generated/dataModel.js";
4
5
  import { contactValidator } from "./validators.js";
5
6
 
6
7
  const LOOPS_API_BASE_URL = "https://app.loops.so/api/v1";
@@ -8,7 +9,7 @@ const LOOPS_API_BASE_URL = "https://app.loops.so/api/v1";
8
9
  /**
9
10
  * Sanitize error messages to avoid leaking sensitive information
10
11
  */
11
- const sanitizeError = (status: number, errorText: string): Error => {
12
+ const sanitizeError = (status: number, _errorText: string): Error => {
12
13
  if (status === 401 || status === 403) {
13
14
  return new Error("Authentication failed. Please check your API key.");
14
15
  }
@@ -120,7 +121,8 @@ export const logEmailOperation = zm({
120
121
  };
121
122
 
122
123
  if (args.actorId) operationData.actorId = args.actorId;
123
- if (args.transactionalId) operationData.transactionalId = args.transactionalId;
124
+ if (args.transactionalId)
125
+ operationData.transactionalId = args.transactionalId;
124
126
  if (args.campaignId) operationData.campaignId = args.campaignId;
125
127
  if (args.loopId) operationData.loopId = args.loopId;
126
128
  if (args.eventName) operationData.eventName = args.eventName;
@@ -143,7 +145,7 @@ export const countContacts = zq({
143
145
  }),
144
146
  returns: z.number(),
145
147
  handler: async (ctx, args) => {
146
- let contacts;
148
+ let contacts: Doc<"contacts">[];
147
149
  if (args.userGroup !== undefined) {
148
150
  contacts = await ctx.db
149
151
  .query("contacts")
@@ -212,8 +214,8 @@ export const listContacts = zq({
212
214
  hasMore: z.boolean(),
213
215
  }),
214
216
  handler: async (ctx, args) => {
215
- let allContacts;
216
-
217
+ let allContacts: Doc<"contacts">[];
218
+
217
219
  // Get all contacts matching the filters
218
220
  if (args.userGroup !== undefined) {
219
221
  allContacts = await ctx.db
@@ -249,7 +251,12 @@ export const listContacts = zq({
249
251
  allContacts.sort((a, b) => b.createdAt - a.createdAt);
250
252
 
251
253
  const total = allContacts.length;
252
- const paginatedContacts = allContacts.slice(args.offset, args.offset + args.limit);
254
+ const paginatedContacts = allContacts
255
+ .slice(args.offset, args.offset + args.limit)
256
+ .map((contact) => ({
257
+ ...contact,
258
+ subscribed: contact.subscribed ?? true, // Ensure subscribed is always boolean
259
+ }));
253
260
  const hasMore = args.offset + args.limit < total;
254
261
 
255
262
  return {
@@ -288,10 +295,12 @@ export const addContact = za({
288
295
 
289
296
  if (!response.ok) {
290
297
  const errorText = await response.text();
291
-
298
+
292
299
  if (response.status === 409) {
293
- console.log(`Contact ${args.contact.email} already exists, updating instead`);
294
-
300
+ console.log(
301
+ `Contact ${args.contact.email} already exists, updating instead`,
302
+ );
303
+
295
304
  const findResponse = await fetch(
296
305
  `${LOOPS_API_BASE_URL}/contacts/find?email=${encodeURIComponent(args.contact.email)}`,
297
306
  {
@@ -300,34 +309,43 @@ export const addContact = za({
300
309
  Authorization: `Bearer ${args.apiKey}`,
301
310
  "Content-Type": "application/json",
302
311
  },
303
- }
312
+ },
304
313
  );
305
314
 
306
315
  if (!findResponse.ok) {
307
316
  const findErrorText = await findResponse.text();
308
- console.error(`Failed to find existing contact [${findResponse.status}]:`, findErrorText);
317
+ console.error(
318
+ `Failed to find existing contact [${findResponse.status}]:`,
319
+ findErrorText,
320
+ );
309
321
  }
310
322
 
311
- const updateResponse = await fetch(`${LOOPS_API_BASE_URL}/contacts/update`, {
312
- method: "PUT",
313
- headers: {
314
- Authorization: `Bearer ${args.apiKey}`,
315
- "Content-Type": "application/json",
323
+ const updateResponse = await fetch(
324
+ `${LOOPS_API_BASE_URL}/contacts/update`,
325
+ {
326
+ method: "PUT",
327
+ headers: {
328
+ Authorization: `Bearer ${args.apiKey}`,
329
+ "Content-Type": "application/json",
330
+ },
331
+ body: JSON.stringify({
332
+ email: args.contact.email,
333
+ firstName: args.contact.firstName,
334
+ lastName: args.contact.lastName,
335
+ userId: args.contact.userId,
336
+ source: args.contact.source,
337
+ subscribed: args.contact.subscribed,
338
+ userGroup: args.contact.userGroup,
339
+ }),
316
340
  },
317
- body: JSON.stringify({
318
- email: args.contact.email,
319
- firstName: args.contact.firstName,
320
- lastName: args.contact.lastName,
321
- userId: args.contact.userId,
322
- source: args.contact.source,
323
- subscribed: args.contact.subscribed,
324
- userGroup: args.contact.userGroup,
325
- }),
326
- });
341
+ );
327
342
 
328
343
  if (!updateResponse.ok) {
329
344
  const updateErrorText = await updateResponse.text();
330
- console.error(`Loops API error [${updateResponse.status}]:`, updateErrorText);
345
+ console.error(
346
+ `Loops API error [${updateResponse.status}]:`,
347
+ updateErrorText,
348
+ );
331
349
  throw sanitizeError(updateResponse.status, updateErrorText);
332
350
  }
333
351
 
@@ -339,7 +357,7 @@ export const addContact = za({
339
357
  }
340
358
 
341
359
  // Store/update in our database
342
- await ctx.runMutation(((internal as any).lib).storeContact as any, {
360
+ await ctx.runMutation((internal as any).lib.storeContact as any, {
343
361
  email: args.contact.email,
344
362
  firstName: args.contact.firstName,
345
363
  lastName: args.contact.lastName,
@@ -364,7 +382,7 @@ export const addContact = za({
364
382
  // Contact was created successfully
365
383
  const data = (await response.json()) as { id?: string };
366
384
 
367
- await ctx.runMutation(((internal as any).lib).storeContact as any, {
385
+ await ctx.runMutation((internal as any).lib.storeContact as any, {
368
386
  email: args.contact.email,
369
387
  firstName: args.contact.firstName,
370
388
  lastName: args.contact.lastName,
@@ -425,7 +443,7 @@ export const updateContact = za({
425
443
  throw sanitizeError(response.status, errorText);
426
444
  }
427
445
 
428
- await ctx.runMutation(((internal as any).lib).storeContact as any, {
446
+ await ctx.runMutation((internal as any).lib.storeContact as any, {
429
447
  email: args.email,
430
448
  firstName: args.firstName,
431
449
  lastName: args.lastName,
@@ -470,19 +488,19 @@ export const sendTransactional = za({
470
488
  if (!response.ok) {
471
489
  const errorText = await response.text();
472
490
  console.error(`Loops API error [${response.status}]:`, errorText);
473
- await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
491
+ await ctx.runMutation((internal as any).lib.logEmailOperation as any, {
474
492
  operationType: "transactional",
475
493
  email: args.email,
476
494
  success: false,
477
495
  transactionalId: args.transactionalId,
478
496
  });
479
-
497
+
480
498
  throw sanitizeError(response.status, errorText);
481
499
  }
482
500
 
483
501
  const data = (await response.json()) as { messageId?: string };
484
502
 
485
- await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
503
+ await ctx.runMutation((internal as any).lib.logEmailOperation as any, {
486
504
  operationType: "transactional",
487
505
  email: args.email,
488
506
  success: true,
@@ -510,7 +528,7 @@ export const sendEvent = za({
510
528
  returns: z.object({
511
529
  success: z.boolean(),
512
530
  }),
513
- handler: async (ctx, args) => {
531
+ handler: async (_ctx, args) => {
514
532
  const response = await fetch(`${LOOPS_API_BASE_URL}/events/send`, {
515
533
  method: "POST",
516
534
  headers: {
@@ -561,7 +579,7 @@ export const deleteContact = za({
561
579
  throw sanitizeError(response.status, errorText);
562
580
  }
563
581
 
564
- await ctx.runMutation(((internal as any).lib).removeContact as any, {
582
+ await ctx.runMutation((internal as any).lib.removeContact as any, {
565
583
  email: args.email,
566
584
  });
567
585
 
@@ -574,15 +592,15 @@ export const deleteContact = za({
574
592
  * Note: Loops in Loops.so are triggered through events, not a direct API endpoint.
575
593
  * This function uses the events endpoint to trigger the loop.
576
594
  * The loop must be configured in the Loops dashboard to listen for events.
577
- *
595
+ *
578
596
  * IMPORTANT: Loops.so doesn't have a direct /loops/trigger endpoint.
579
597
  * Loops are triggered by sending events. Make sure your loop in the dashboard
580
598
  * is configured to trigger on an event name (e.g., "loop_trigger").
581
- *
599
+ *
582
600
  * If you need to trigger a specific loop, you should:
583
601
  * 1. Configure the loop in the dashboard to listen for a specific event name
584
602
  * 2. Use sendEvent() with that event name instead
585
- *
603
+ *
586
604
  * This function is kept for backwards compatibility but works by sending an event.
587
605
  */
588
606
  export const triggerLoop = za({
@@ -602,10 +620,10 @@ export const triggerLoop = za({
602
620
  // Loops are triggered through events. We'll use the events endpoint.
603
621
  // Default event name if not provided
604
622
  const eventName = args.eventName || `loop_${args.loopId}`;
605
-
623
+
606
624
  try {
607
625
  // Send event to trigger the loop
608
- await ctx.runAction(((internal as any).lib).sendEvent as any, {
626
+ await ctx.runAction((internal as any).lib.sendEvent as any, {
609
627
  apiKey: args.apiKey,
610
628
  email: args.email,
611
629
  eventName,
@@ -616,7 +634,7 @@ export const triggerLoop = za({
616
634
  });
617
635
 
618
636
  // Log as loop operation
619
- await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
637
+ await ctx.runMutation((internal as any).lib.logEmailOperation as any, {
620
638
  operationType: "loop",
621
639
  email: args.email,
622
640
  success: true,
@@ -626,19 +644,22 @@ export const triggerLoop = za({
626
644
 
627
645
  return {
628
646
  success: true,
629
- warning: "Loops are triggered via events. Ensure your loop is configured to listen for this event.",
647
+ warning:
648
+ "Loops are triggered via events. Ensure your loop is configured to listen for this event.",
630
649
  };
631
650
  } catch (error) {
632
651
  // Log failed loop operation
633
- await ctx.runMutation(((internal as any).lib).logEmailOperation as any, {
652
+ await ctx.runMutation((internal as any).lib.logEmailOperation as any, {
634
653
  operationType: "loop",
635
654
  email: args.email,
636
655
  success: false,
637
656
  loopId: args.loopId,
638
657
  eventName,
639
- metadata: { error: error instanceof Error ? error.message : String(error) },
658
+ metadata: {
659
+ error: error instanceof Error ? error.message : String(error),
660
+ },
640
661
  });
641
-
662
+
642
663
  throw error;
643
664
  }
644
665
  },
@@ -670,7 +691,7 @@ export const findContact = za({
670
691
  })
671
692
  .optional(),
672
693
  }),
673
- handler: async (ctx, args) => {
694
+ handler: async (_ctx, args) => {
674
695
  const response = await fetch(
675
696
  `${LOOPS_API_BASE_URL}/contacts/find?email=${encodeURIComponent(args.email)}`,
676
697
  {
@@ -691,7 +712,9 @@ export const findContact = za({
691
712
  throw sanitizeError(response.status, errorText);
692
713
  }
693
714
 
694
- const data = (await response.json()) as Record<string, any> | Array<Record<string, any>>;
715
+ const data = (await response.json()) as
716
+ | Record<string, any>
717
+ | Array<Record<string, any>>;
695
718
 
696
719
  // Handle case where Loops returns an array instead of a single object
697
720
  let contact = Array.isArray(data) ? data[0] : data;
@@ -699,7 +722,10 @@ export const findContact = za({
699
722
  // Convert null values to undefined for optional fields (Zod handles undefined but not null in optional())
700
723
  if (contact) {
701
724
  contact = Object.fromEntries(
702
- Object.entries(contact).map(([key, value]) => [key, value === null ? undefined : value])
725
+ Object.entries(contact).map(([key, value]) => [
726
+ key,
727
+ value === null ? undefined : value,
728
+ ]),
703
729
  ) as Record<string, any>;
704
730
  }
705
731
 
@@ -724,35 +750,43 @@ export const batchCreateContacts = za({
724
750
  success: z.boolean(),
725
751
  created: z.number().optional(),
726
752
  failed: z.number().optional(),
727
- results: z.array(z.object({
728
- email: z.string(),
729
- success: z.boolean(),
730
- error: z.string().optional(),
731
- })).optional(),
753
+ results: z
754
+ .array(
755
+ z.object({
756
+ email: z.string(),
757
+ success: z.boolean(),
758
+ error: z.string().optional(),
759
+ }),
760
+ )
761
+ .optional(),
732
762
  }),
733
763
  handler: async (ctx, args) => {
734
764
  let created = 0;
735
765
  let failed = 0;
736
- const results: Array<{ email: string; success: boolean; error?: string }> = [];
766
+ const results: Array<{ email: string; success: boolean; error?: string }> =
767
+ [];
737
768
 
738
769
  // Create contacts one by one since Loops.so doesn't have a batch endpoint
739
770
  for (const contact of args.contacts) {
740
771
  try {
741
772
  // Use the addContact function which handles create/update logic
742
- const result = await ctx.runAction(((internal as any).lib).addContact as any, {
743
- apiKey: args.apiKey,
744
- contact,
745
- });
773
+ const result = await ctx.runAction(
774
+ (internal as any).lib.addContact as any,
775
+ {
776
+ apiKey: args.apiKey,
777
+ contact,
778
+ },
779
+ );
746
780
 
747
781
  if (result.success) {
748
782
  created++;
749
783
  results.push({ email: contact.email, success: true });
750
784
  } else {
751
785
  failed++;
752
- results.push({
753
- email: contact.email,
754
- success: false,
755
- error: "Unknown error"
786
+ results.push({
787
+ email: contact.email,
788
+ success: false,
789
+ error: "Unknown error",
756
790
  });
757
791
  }
758
792
  } catch (error) {
@@ -802,7 +836,7 @@ export const unsubscribeContact = za({
802
836
  throw sanitizeError(response.status, errorText);
803
837
  }
804
838
 
805
- await ctx.runMutation(((internal as any).lib).storeContact as any, {
839
+ await ctx.runMutation((internal as any).lib.storeContact as any, {
806
840
  email: args.email,
807
841
  subscribed: false,
808
842
  });
@@ -839,7 +873,7 @@ export const resubscribeContact = za({
839
873
  throw sanitizeError(response.status, errorText);
840
874
  }
841
875
 
842
- await ctx.runMutation(((internal as any).lib).storeContact as any, {
876
+ await ctx.runMutation((internal as any).lib.storeContact as any, {
843
877
  email: args.email,
844
878
  subscribed: true,
845
879
  });
@@ -858,15 +892,17 @@ export const detectRecipientSpam = zq({
858
892
  maxEmailsPerRecipient: z.number().default(10),
859
893
  }),
860
894
  returns: z.array(
861
- z.object({
862
- email: z.string(),
863
- count: z.number(),
864
- timeWindowMs: z.number(),
865
- }),
895
+ z
896
+ .object({
897
+ email: z.string(),
898
+ count: z.number(),
899
+ timeWindowMs: z.number(),
900
+ })
901
+ .catchall(z.any()),
866
902
  ),
867
903
  handler: async (ctx, args) => {
868
904
  const cutoffTime = Date.now() - args.timeWindowMs;
869
-
905
+
870
906
  const operations = await ctx.db
871
907
  .query("emailOperations")
872
908
  .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
@@ -879,7 +915,11 @@ export const detectRecipientSpam = zq({
879
915
  }
880
916
  }
881
917
 
882
- const suspicious: Array<{ email: string; count: number; timeWindowMs: number }> = [];
918
+ const suspicious: Array<{
919
+ email: string;
920
+ count: number;
921
+ timeWindowMs: number;
922
+ }> = [];
883
923
  for (const [email, count] of emailCounts.entries()) {
884
924
  if (count > args.maxEmailsPerRecipient) {
885
925
  suspicious.push({
@@ -912,7 +952,7 @@ export const detectActorSpam = zq({
912
952
  ),
913
953
  handler: async (ctx, args) => {
914
954
  const cutoffTime = Date.now() - args.timeWindowMs;
915
-
955
+
916
956
  const operations = await ctx.db
917
957
  .query("emailOperations")
918
958
  .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
@@ -925,7 +965,11 @@ export const detectActorSpam = zq({
925
965
  }
926
966
  }
927
967
 
928
- const suspicious: Array<{ actorId: string; count: number; timeWindowMs: number }> = [];
968
+ const suspicious: Array<{
969
+ actorId: string;
970
+ count: number;
971
+ timeWindowMs: number;
972
+ }> = [];
929
973
  for (const [actorId, count] of actorCounts.entries()) {
930
974
  if (count > args.maxEmailsPerActor) {
931
975
  suspicious.push({
@@ -947,17 +991,19 @@ export const getEmailStats = zq({
947
991
  args: z.object({
948
992
  timeWindowMs: z.number().default(86400000),
949
993
  }),
950
- returns: z.object({
951
- totalOperations: z.number(),
952
- successfulOperations: z.number(),
953
- failedOperations: z.number(),
954
- operationsByType: z.record(z.string(), z.number()),
955
- uniqueRecipients: z.number(),
956
- uniqueActors: z.number(),
957
- }),
994
+ returns: z
995
+ .object({
996
+ totalOperations: z.number(),
997
+ successfulOperations: z.number(),
998
+ failedOperations: z.number(),
999
+ operationsByType: z.record(z.string(), z.number()),
1000
+ uniqueRecipients: z.number(),
1001
+ uniqueActors: z.number(),
1002
+ })
1003
+ .catchall(z.any()),
958
1004
  handler: async (ctx, args) => {
959
1005
  const cutoffTime = Date.now() - args.timeWindowMs;
960
-
1006
+
961
1007
  const operations = await ctx.db
962
1008
  .query("emailOperations")
963
1009
  .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
@@ -975,11 +1021,11 @@ export const getEmailStats = zq({
975
1021
  for (const op of operations) {
976
1022
  stats.operationsByType[op.operationType] =
977
1023
  (stats.operationsByType[op.operationType] ?? 0) + 1;
978
-
1024
+
979
1025
  if (op.email && op.email !== "audience") {
980
1026
  stats.uniqueRecipients.add(op.email);
981
1027
  }
982
-
1028
+
983
1029
  if (op.actorId) {
984
1030
  stats.uniqueActors.add(op.actorId);
985
1031
  }
@@ -1014,7 +1060,7 @@ export const detectRapidFirePatterns = zq({
1014
1060
  ),
1015
1061
  handler: async (ctx, args) => {
1016
1062
  const cutoffTime = Date.now() - args.timeWindowMs;
1017
-
1063
+
1018
1064
  const operations = await ctx.db
1019
1065
  .query("emailOperations")
1020
1066
  .withIndex("timestamp", (q) => q.gte("timestamp", cutoffTime))
@@ -1037,7 +1083,7 @@ export const detectRapidFirePatterns = zq({
1037
1083
  if (!emailGroups.has(op.email)) {
1038
1084
  emailGroups.set(op.email, []);
1039
1085
  }
1040
- emailGroups.get(op.email)!.push(op);
1086
+ emailGroups.get(op.email)?.push(op);
1041
1087
  }
1042
1088
  }
1043
1089
 
@@ -1045,13 +1091,13 @@ export const detectRapidFirePatterns = zq({
1045
1091
  for (let i = 0; i < ops.length; i++) {
1046
1092
  const op = ops[i];
1047
1093
  if (!op) continue;
1048
-
1094
+
1049
1095
  const windowStart = op.timestamp;
1050
1096
  const windowEnd = windowStart + args.timeWindowMs;
1051
1097
  const opsInWindow = ops.filter(
1052
1098
  (op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
1053
1099
  );
1054
-
1100
+
1055
1101
  if (opsInWindow.length >= args.minEmailsInWindow) {
1056
1102
  patterns.push({
1057
1103
  email,
@@ -1070,7 +1116,7 @@ export const detectRapidFirePatterns = zq({
1070
1116
  if (!actorGroups.has(op.actorId)) {
1071
1117
  actorGroups.set(op.actorId, []);
1072
1118
  }
1073
- actorGroups.get(op.actorId)!.push(op);
1119
+ actorGroups.get(op.actorId)?.push(op);
1074
1120
  }
1075
1121
  }
1076
1122
 
@@ -1078,13 +1124,13 @@ export const detectRapidFirePatterns = zq({
1078
1124
  for (let i = 0; i < ops.length; i++) {
1079
1125
  const op = ops[i];
1080
1126
  if (!op) continue;
1081
-
1127
+
1082
1128
  const windowStart = op.timestamp;
1083
1129
  const windowEnd = windowStart + args.timeWindowMs;
1084
1130
  const opsInWindow = ops.filter(
1085
1131
  (op) => op.timestamp >= windowStart && op.timestamp <= windowEnd,
1086
1132
  );
1087
-
1133
+
1088
1134
  if (opsInWindow.length >= args.minEmailsInWindow) {
1089
1135
  patterns.push({
1090
1136
  actorId,
@@ -1111,13 +1157,15 @@ export const checkRecipientRateLimit = zq({
1111
1157
  timeWindowMs: z.number(),
1112
1158
  maxEmails: z.number(),
1113
1159
  }),
1114
- returns: z.object({
1115
- allowed: z.boolean(),
1116
- count: z.number(),
1117
- limit: z.number(),
1118
- timeWindowMs: z.number(),
1119
- retryAfter: z.number().optional(),
1120
- }),
1160
+ returns: z
1161
+ .object({
1162
+ allowed: z.boolean(),
1163
+ count: z.number(),
1164
+ limit: z.number(),
1165
+ timeWindowMs: z.number(),
1166
+ retryAfter: z.number().optional(),
1167
+ })
1168
+ .catchall(z.any()),
1121
1169
  handler: async (ctx, args) => {
1122
1170
  const cutoffTime = Date.now() - args.timeWindowMs;
1123
1171
 
@@ -13,4 +13,3 @@ export const Contacts = zodTable("contacts", {
13
13
  createdAt: z.number(),
14
14
  updatedAt: z.number(),
15
15
  });
16
-
@@ -2,12 +2,7 @@ import { z } from "zod";
2
2
  import { zodTable } from "zodvex";
3
3
 
4
4
  export const EmailOperations = zodTable("emailOperations", {
5
- operationType: z.enum([
6
- "transactional",
7
- "event",
8
- "campaign",
9
- "loop",
10
- ]),
5
+ operationType: z.enum(["transactional", "event", "campaign", "loop"]),
11
6
  email: z.string().email(),
12
7
  actorId: z.string().optional(),
13
8
  transactionalId: z.string().optional(),
@@ -19,4 +14,3 @@ export const EmailOperations = zodTable("emailOperations", {
19
14
  messageId: z.string().optional(),
20
15
  metadata: z.optional(z.record(z.string(), z.any())),
21
16
  });
22
-
@@ -36,4 +36,3 @@ export const eventValidator = z.object({
36
36
  eventName: z.string(),
37
37
  eventProperties: z.record(z.string(), z.any()).optional(),
38
38
  });
39
-