@devwithbobby/loops 0.1.17 → 0.1.18

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
@@ -6,13 +6,13 @@ A Convex component for integrating with [Loops.so](https://loops.so) email marke
6
6
 
7
7
  ## Features
8
8
 
9
- - **Contact Management** - Create, update, find, and delete contacts
10
- - **Transactional Emails** - Send one-off emails with templates
11
- - **Events** - Trigger email workflows based on events
12
- - **Loops** - Trigger automated email sequences
13
- - **Monitoring** - Track all email operations with spam detection
14
- - **Rate Limiting** - Built-in rate limiting queries for abuse prevention
15
- - **Type-Safe** - Full TypeScript support with Zod validation
9
+ - **Contact Management** - Create, update, find, list, and delete contacts
10
+ - **Transactional Emails** - Send one-off emails with templates
11
+ - **Events** - Trigger email workflows based on events
12
+ - **Loops** - Trigger automated email sequences
13
+ - **Monitoring** - Track all email operations with spam detection
14
+ - **Rate Limiting** - Built-in rate limiting queries for abuse prevention
15
+ - **Type-Safe** - Full TypeScript support with Zod validation
16
16
 
17
17
  ## Installation
18
18
 
@@ -40,17 +40,17 @@ export default app;
40
40
 
41
41
  ### 2. Set Up Environment Variables
42
42
 
43
- **⚠️ IMPORTANT: Set your Loops API key before using the component.**
43
+ **IMPORTANT: Set your Loops API key before using the component.**
44
44
 
45
45
  ```bash
46
46
  npx convex env set LOOPS_API_KEY "your-loops-api-key-here"
47
47
  ```
48
48
 
49
49
  **Or via Convex Dashboard:**
50
- 1. Go to Settings Environment Variables
50
+ 1. Go to Settings -> Environment Variables
51
51
  2. Add `LOOPS_API_KEY` with your Loops.so API key
52
52
 
53
- Get your API key from [Loops.so Dashboard](https://app.loops.so/settings/api).
53
+ Get your API key from [Loops.so Dashboard](https://app.loops.so/settings?page=api).
54
54
 
55
55
  ### 3. Use the Component
56
56
 
@@ -135,6 +135,27 @@ await loops.updateContact(ctx, "user@example.com", {
135
135
  const contact = await loops.findContact(ctx, "user@example.com");
136
136
  ```
137
137
 
138
+ #### List Contacts
139
+
140
+ List contacts with pagination and optional filtering.
141
+
142
+ ```typescript
143
+ // Simple list with default limit (100)
144
+ const result = await loops.listContacts(ctx);
145
+
146
+ // List with filters and pagination
147
+ const result = await loops.listContacts(ctx, {
148
+ userGroup: "premium",
149
+ subscribed: true,
150
+ limit: 20,
151
+ offset: 0
152
+ });
153
+
154
+ console.log(result.contacts); // Array of contacts
155
+ console.log(result.total); // Total count matching filters
156
+ console.log(result.hasMore); // Boolean indicating if more pages exist
157
+ ```
158
+
138
159
  #### Delete Contact
139
160
 
140
161
  ```typescript
@@ -335,11 +356,12 @@ export const {
335
356
  sendEvent,
336
357
  triggerLoop,
337
358
  countContacts,
359
+ listContacts,
338
360
  // ... all other functions
339
361
  } = loops.api();
340
362
  ```
341
363
 
342
- **⚠️ Security Warning:** The `api()` helper exports functions without authentication. Always wrap these functions with auth checks in production:
364
+ **Security Warning:** The `api()` helper exports functions without authentication. Always wrap these functions with auth checks in production:
343
365
 
344
366
  ```typescript
345
367
  export const addContact = action({
@@ -392,12 +414,12 @@ npx convex env set LOOPS_API_KEY "your-api-key"
392
414
 
393
415
  **Via Dashboard:**
394
416
  1. Go to your Convex Dashboard
395
- 2. Navigate to Settings Environment Variables
417
+ 2. Navigate to Settings -> Environment Variables
396
418
  3. Add `LOOPS_API_KEY` with your Loops.so API key value
397
419
 
398
- Get your API key from [Loops.so Dashboard](https://app.loops.so/settings/api).
420
+ Get your API key from [Loops.so Dashboard](https://app.loops.so/settings?page=api).
399
421
 
400
- ⚠️ **Never** pass the API key directly in code or via function options in production. Always use environment variables.
422
+ **Never** pass the API key directly in code or via function options in production. Always use environment variables.
401
423
 
402
424
  ## Monitoring & Rate Limiting
403
425
 
@@ -458,15 +480,16 @@ example/ # Example app
458
480
 
459
481
  This component implements the following Loops.so API endpoints:
460
482
 
461
- - Create/Update Contact
462
- - Delete Contact
463
- - Find Contact
464
- - Batch Create Contacts
465
- - Unsubscribe/Resubscribe Contact
466
- - Count Contacts (custom implementation)
467
- - Send Transactional Email
468
- - Send Event
469
- - Trigger Loop
483
+ - Create/Update Contact
484
+ - Delete Contact
485
+ - Find Contact
486
+ - Batch Create Contacts
487
+ - Unsubscribe/Resubscribe Contact
488
+ - Count Contacts (custom implementation)
489
+ - List Contacts (custom implementation)
490
+ - Send Transactional Email
491
+ - Send Event
492
+ - Trigger Loop
470
493
 
471
494
  ## Contributing
472
495
 
@@ -373,6 +373,31 @@ export declare class Loops {
373
373
  subscribed?: boolean | undefined;
374
374
  userGroup?: string | undefined;
375
375
  }, Promise<number>>;
376
+ listContacts: import("convex/server").RegisteredQuery<"public", {
377
+ source?: string | undefined;
378
+ subscribed?: boolean | undefined;
379
+ userGroup?: string | undefined;
380
+ limit?: number | undefined;
381
+ offset?: number | undefined;
382
+ }, Promise<{
383
+ contacts: {
384
+ _id: string;
385
+ email: string;
386
+ subscribed: boolean;
387
+ createdAt: number;
388
+ updatedAt: number;
389
+ firstName?: string | undefined;
390
+ lastName?: string | undefined;
391
+ userId?: string | undefined;
392
+ source?: string | undefined;
393
+ userGroup?: string | undefined;
394
+ loopsContactId?: string | undefined;
395
+ }[];
396
+ total: number;
397
+ limit: number;
398
+ offset: number;
399
+ hasMore: boolean;
400
+ }>>;
376
401
  detectRecipientSpam: import("convex/server").RegisteredQuery<"public", {
377
402
  timeWindowMs?: number | undefined;
378
403
  maxEmailsPerRecipient?: number | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"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"}
@@ -24,7 +24,7 @@ export class Loops {
24
24
  if (options?.apiKey) {
25
25
  console.warn("API key passed directly via options. " +
26
26
  "For security, use LOOPS_API_KEY environment variable instead. " +
27
- "See ENV_SETUP.md for details.");
27
+ "See README.md for details.");
28
28
  }
29
29
  this.apiKey = apiKey;
30
30
  }
@@ -333,6 +333,18 @@ export class Loops {
333
333
  return await this.countContacts(ctx, args);
334
334
  },
335
335
  }),
336
+ listContacts: queryGeneric({
337
+ args: {
338
+ userGroup: v.optional(v.string()),
339
+ source: v.optional(v.string()),
340
+ subscribed: v.optional(v.boolean()),
341
+ limit: v.optional(v.number()),
342
+ offset: v.optional(v.number()),
343
+ },
344
+ handler: async (ctx, args) => {
345
+ return await this.listContacts(ctx, args);
346
+ },
347
+ }),
336
348
  detectRecipientSpam: queryGeneric({
337
349
  args: {
338
350
  timeWindowMs: v.optional(v.number()),
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../src/component/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAWjD,eAAO,MAAM,kBAAkB,gCAAgC,CAAC;AAEhE,eAAO,MAAM,kBAAkB,WACtB,MAAM,cACF,MAAM,KAChB,KAcF,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,GAAG;IAC1D,IAAI,CAAC,EAAE,OAAO,CAAC;CACf,CAAC;AAEF,eAAO,MAAM,UAAU,WACd,MAAM,QACR,MAAM,SACN,gBAAgB,sBAetB,CAAC;AAEF,eAAO,MAAM,gBAAgB,WAAY,gBAAgB,YAQxD,CAAC;AAEF,eAAO,MAAM,YAAY,SAAU,OAAO,SAAS,YAAY,aAM9D,CAAC;AAEF,eAAO,MAAM,aAAa,UAAW,YAAY,aAKhD,CAAC;AAEF,eAAO,MAAM,YAAY,GAAU,CAAC,WAAW,OAAO,KAAG,OAAO,CAAC,CAAC,CAMjE,CAAC;AAEF,eAAO,MAAM,gBAAgB,UAAW,MAAM,GAAG,IAAI,wBAWpD,CAAC;AAEF,eAAO,MAAM,eAAe,UAAW,MAAM,GAAG,IAAI,YAAY,MAAM,WAMrE,CAAC;AAEF,eAAO,MAAM,kBAAkB,cAQ9B,CAAC;AAEF,eAAO,MAAM,YAAY,UAAW,OAAO,aAS1C,CAAC"}
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../src/component/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAWjD,eAAO,MAAM,kBAAkB,gCAAgC,CAAC;AAEhE,eAAO,MAAM,kBAAkB,GAC9B,QAAQ,MAAM,EACd,YAAY,MAAM,KAChB,KAcF,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,GAAG;IAC1D,IAAI,CAAC,EAAE,OAAO,CAAC;CACf,CAAC;AAEF,eAAO,MAAM,UAAU,GACtB,QAAQ,MAAM,EACd,MAAM,MAAM,EACZ,OAAM,gBAAqB,sBAe3B,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,QAAQ,gBAAgB,YAQxD,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,MAAM,OAAO,EAAE,OAAO,YAAY,aAM9D,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,OAAO,YAAY,aAKhD,CAAC;AAEF,eAAO,MAAM,YAAY,GAAU,CAAC,EAAE,SAAS,OAAO,KAAG,OAAO,CAAC,CAAC,CAMjE,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,OAAO,MAAM,GAAG,IAAI,wBAWpD,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,OAAO,MAAM,GAAG,IAAI,EAAE,UAAU,MAAM,WAMrE,CAAC;AAEF,eAAO,MAAM,kBAAkB,cAQ9B,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,aAS1C,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/component/http.ts"],"names":[],"mappings":"AAsBA,QAAA,MAAM,IAAI,oCAAe,CAAC;AAwN1B,eAAe,IAAI,CAAC"}
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/component/http.ts"],"names":[],"mappings":"AAsBA,QAAA,MAAM,IAAI,oCAAe,CAAC;AAoN1B,eAAe,IAAI,CAAC"}
@@ -3,7 +3,6 @@ import { internalLib, } from "../types";
3
3
  import { buildCorsHeaders, jsonResponse, emptyResponse, readJsonBody, respondError, booleanFromQuery, numberFromQuery, requireLoopsApiKey, } from "./helpers";
4
4
  import { httpAction } from "./_generated/server";
5
5
  const http = httpRouter();
6
- const allowedOrigin = process.env.LOOPS_HTTP_ALLOWED_ORIGIN ?? process.env.CLIENT_ORIGIN ?? "*";
7
6
  http.route({
8
7
  pathPrefix: "/loops/",
9
8
  method: "OPTIONS",
@@ -35,6 +35,10 @@ export declare const logEmailOperation: import("convex/server").RegisteredMutati
35
35
  /**
36
36
  * Count contacts in the database
37
37
  * Can filter by audience criteria (userGroup, source, subscribed status)
38
+ *
39
+ * Note: When multiple filters are provided, only one index can be used.
40
+ * Additional filters are applied in-memory, which is efficient for small result sets.
41
+ * For large contact lists with multiple filters, consider using a composite index.
38
42
  */
39
43
  export declare const countContacts: import("convex/server").RegisteredQuery<"public", {
40
44
  userGroup?: string | undefined;
@@ -45,6 +49,9 @@ export declare const countContacts: import("convex/server").RegisteredQuery<"pub
45
49
  * List contacts from the database with pagination
46
50
  * Can filter by audience criteria (userGroup, source, subscribed status)
47
51
  * Returns actual contact data, not just a count
52
+ *
53
+ * Note: When multiple filters are provided, only one index can be used.
54
+ * Additional filters are applied in-memory before pagination.
48
55
  */
49
56
  export declare const listContacts: import("convex/server").RegisteredQuery<"public", {
50
57
  limit: number;
@@ -1 +1 @@
1
- {"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../../src/component/lib.ts"],"names":[],"mappings":"AAOA;;GAEG;AACH,eAAO,MAAM,YAAY;;;;;;;;;iBA6CvB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa;;iBAexB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB;;;;;;;;;;;iBAkC5B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,aAAa;;;;mBAwCxB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;GAmFvB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,UAAU;;;;;;;;;;;;;;GA+GrB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;GAgDxB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB;;;;;;;;GAiD5B,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,SAAS;;;;;;;GA4BpB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa;;;;;GA0BxB,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,WAAW;;;;;;;;;GA4DtB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;GA4DtB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;GA8D9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;;GA2B7B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;;GA2B7B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;KA8C9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,eAAe;;;;;;;KA4C1B,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;GAkDxB,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,uBAAuB;;;;;;;;;;KAsGlC,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,uBAAuB;;;;;;;;;;;GA+ClC,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;GA6C9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;GA8B/B,CAAC"}
1
+ {"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../../src/component/lib.ts"],"names":[],"mappings":"AAOA;;GAEG;AACH,eAAO,MAAM,YAAY;;;;;;;;;iBA6CvB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa;;iBAexB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB;;;;;;;;;;;iBAkC5B,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa;;;;mBA0DxB,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;GA8FvB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,UAAU;;;;;;;;;;;;;;GA+GrB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;GAgDxB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB;;;;;;;;GAiD5B,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,SAAS;;;;;;;GAyCpB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa;;;;;GA0BxB,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,WAAW;;;;;;;;;GA4DtB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;GA4DtB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;GA8D9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;;GA2B7B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;;GA2B7B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;KA8C9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,eAAe;;;;;;;KA4C1B,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;GAkDxB,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,uBAAuB;;;;;;;;;;KAsGlC,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,uBAAuB;;;;;;;;;;;GA+ClC,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;GA6C9B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;GA8B/B,CAAC"}
@@ -107,6 +107,10 @@ export const logEmailOperation = zm({
107
107
  /**
108
108
  * Count contacts in the database
109
109
  * Can filter by audience criteria (userGroup, source, subscribed status)
110
+ *
111
+ * Note: When multiple filters are provided, only one index can be used.
112
+ * Additional filters are applied in-memory, which is efficient for small result sets.
113
+ * For large contact lists with multiple filters, consider using a composite index.
110
114
  */
111
115
  export const countContacts = zq({
112
116
  args: z.object({
@@ -116,6 +120,8 @@ export const countContacts = zq({
116
120
  }),
117
121
  returns: z.number(),
118
122
  handler: async (ctx, args) => {
123
+ // Build query using the most selective index available
124
+ // Priority: userGroup > source > subscribed
119
125
  let contacts;
120
126
  if (args.userGroup !== undefined) {
121
127
  contacts = await ctx.db
@@ -138,22 +144,37 @@ export const countContacts = zq({
138
144
  else {
139
145
  contacts = await ctx.db.query("contacts").collect();
140
146
  }
141
- if (args.userGroup !== undefined && contacts) {
142
- contacts = contacts.filter((c) => c.userGroup === args.userGroup);
143
- }
144
- if (args.source !== undefined && contacts) {
145
- contacts = contacts.filter((c) => c.source === args.source);
147
+ // Apply additional filters if multiple criteria were provided
148
+ // This avoids redundant filtering when only one filter was used
149
+ const needsFiltering = (args.userGroup !== undefined ? 1 : 0) +
150
+ (args.source !== undefined ? 1 : 0) +
151
+ (args.subscribed !== undefined ? 1 : 0) >
152
+ 1;
153
+ if (!needsFiltering) {
154
+ return contacts.length;
146
155
  }
147
- if (args.subscribed !== undefined && contacts) {
148
- contacts = contacts.filter((c) => c.subscribed === args.subscribed);
149
- }
150
- return contacts.length;
156
+ const filtered = contacts.filter((c) => {
157
+ if (args.userGroup !== undefined && c.userGroup !== args.userGroup) {
158
+ return false;
159
+ }
160
+ if (args.source !== undefined && c.source !== args.source) {
161
+ return false;
162
+ }
163
+ if (args.subscribed !== undefined && c.subscribed !== args.subscribed) {
164
+ return false;
165
+ }
166
+ return true;
167
+ });
168
+ return filtered.length;
151
169
  },
152
170
  });
153
171
  /**
154
172
  * List contacts from the database with pagination
155
173
  * Can filter by audience criteria (userGroup, source, subscribed status)
156
174
  * Returns actual contact data, not just a count
175
+ *
176
+ * Note: When multiple filters are provided, only one index can be used.
177
+ * Additional filters are applied in-memory before pagination.
157
178
  */
158
179
  export const listContacts = zq({
159
180
  args: z.object({
@@ -183,8 +204,8 @@ export const listContacts = zq({
183
204
  hasMore: z.boolean(),
184
205
  }),
185
206
  handler: async (ctx, args) => {
207
+ // Build query using the most selective index available
186
208
  let allContacts;
187
- // Get all contacts matching the filters
188
209
  if (args.userGroup !== undefined) {
189
210
  allContacts = await ctx.db
190
211
  .query("contacts")
@@ -206,15 +227,24 @@ export const listContacts = zq({
206
227
  else {
207
228
  allContacts = await ctx.db.query("contacts").collect();
208
229
  }
209
- // Apply additional filters (for cases where we need to filter by multiple criteria)
210
- if (args.userGroup !== undefined && allContacts) {
211
- allContacts = allContacts.filter((c) => c.userGroup === args.userGroup);
212
- }
213
- if (args.source !== undefined && allContacts) {
214
- allContacts = allContacts.filter((c) => c.source === args.source);
215
- }
216
- if (args.subscribed !== undefined && allContacts) {
217
- allContacts = allContacts.filter((c) => c.subscribed === args.subscribed);
230
+ // Apply additional filters if multiple criteria were provided
231
+ const needsFiltering = (args.userGroup !== undefined ? 1 : 0) +
232
+ (args.source !== undefined ? 1 : 0) +
233
+ (args.subscribed !== undefined ? 1 : 0) >
234
+ 1;
235
+ if (needsFiltering) {
236
+ allContacts = allContacts.filter((c) => {
237
+ if (args.userGroup !== undefined && c.userGroup !== args.userGroup) {
238
+ return false;
239
+ }
240
+ if (args.source !== undefined && c.source !== args.source) {
241
+ return false;
242
+ }
243
+ if (args.subscribed !== undefined && c.subscribed !== args.subscribed) {
244
+ return false;
245
+ }
246
+ return true;
247
+ });
218
248
  }
219
249
  // Sort by createdAt (newest first)
220
250
  allContacts.sort((a, b) => b.createdAt - a.createdAt);
@@ -433,7 +463,7 @@ export const sendEvent = za({
433
463
  returns: z.object({
434
464
  success: z.boolean(),
435
465
  }),
436
- handler: async (_ctx, args) => {
466
+ handler: async (ctx, args) => {
437
467
  const response = await loopsFetch(args.apiKey, "/events/send", {
438
468
  method: "POST",
439
469
  json: {
@@ -445,8 +475,20 @@ export const sendEvent = za({
445
475
  if (!response.ok) {
446
476
  const errorText = await response.text();
447
477
  console.error(`Loops API error [${response.status}]:`, errorText);
478
+ await ctx.runMutation(internalLib.logEmailOperation, {
479
+ operationType: "event",
480
+ email: args.email,
481
+ success: false,
482
+ eventName: args.eventName,
483
+ });
448
484
  throw sanitizeLoopsError(response.status, errorText);
449
485
  }
486
+ await ctx.runMutation(internalLib.logEmailOperation, {
487
+ operationType: "event",
488
+ email: args.email,
489
+ success: true,
490
+ eventName: args.eventName,
491
+ });
450
492
  return { success: true };
451
493
  },
452
494
  });
@@ -29,13 +29,13 @@ declare const _default: import("convex/server").SchemaDefinition<{
29
29
  subscribed: ["subscribed", "_creationTime"];
30
30
  }, {}, {}>;
31
31
  emailOperations: import("convex/server").TableDefinition<import("convex/values").VObject<{
32
+ metadata?: Record<string, any> | undefined;
32
33
  actorId?: string | undefined;
33
34
  transactionalId?: string | undefined;
34
35
  campaignId?: string | undefined;
35
36
  loopId?: string | undefined;
36
37
  eventName?: string | undefined;
37
38
  messageId?: string | undefined;
38
- metadata?: Record<string, any> | undefined;
39
39
  email: string;
40
40
  success: boolean;
41
41
  operationType: "transactional" | "event" | "campaign" | "loop";
@@ -57,7 +57,7 @@ declare const _default: import("convex/server").SchemaDefinition<{
57
57
  success: import("zod").ZodBoolean;
58
58
  messageId: import("zod").ZodOptional<import("zod").ZodString>;
59
59
  metadata: import("zod").ZodOptional<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodAny>>;
60
- }>, "required", "email" | "success" | "operationType" | "actorId" | "transactionalId" | "campaignId" | "loopId" | "eventName" | "timestamp" | "messageId" | "metadata" | `metadata.${string}`>, {
60
+ }>, "required", "email" | "success" | "metadata" | "operationType" | "actorId" | "transactionalId" | "campaignId" | "loopId" | "eventName" | "timestamp" | "messageId" | `metadata.${string}`>, {
61
61
  email: ["email", "timestamp", "_creationTime"];
62
62
  actorId: ["actorId", "timestamp", "_creationTime"];
63
63
  operationType: ["operationType", "timestamp", "_creationTime"];
@@ -1 +1 @@
1
- {"version":3,"file":"contacts.d.ts","sourceRoot":"","sources":["../../../src/component/tables/contacts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBAYu99B,EAAG,SAAS;;;;;;;;uBAAgW,EAAG,SAAS;;CADl2+B,CAAC"}
1
+ {"version":3,"file":"contacts.d.ts","sourceRoot":"","sources":["../../../src/component/tables/contacts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAWnB,CAAC"}
@@ -2,13 +2,13 @@ import { z } from "zod";
2
2
  export declare const EmailOperations: {
3
3
  name: "emailOperations";
4
4
  table: import("convex/server").TableDefinition<import("convex/values").VObject<{
5
+ metadata?: Record<string, any> | undefined;
5
6
  actorId?: string | undefined;
6
7
  transactionalId?: string | undefined;
7
8
  campaignId?: string | undefined;
8
9
  loopId?: string | undefined;
9
10
  eventName?: string | undefined;
10
11
  messageId?: string | undefined;
11
- metadata?: Record<string, any> | undefined;
12
12
  email: string;
13
13
  success: boolean;
14
14
  operationType: "transactional" | "event" | "campaign" | "loop";
@@ -30,15 +30,15 @@ export declare const EmailOperations: {
30
30
  success: z.ZodBoolean;
31
31
  messageId: z.ZodOptional<z.ZodString>;
32
32
  metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
33
- }>, "required", "email" | "success" | "operationType" | "actorId" | "transactionalId" | "campaignId" | "loopId" | "eventName" | "timestamp" | "messageId" | "metadata" | `metadata.${string}`>, {}, {}, {}>;
33
+ }>, "required", "email" | "success" | "metadata" | "operationType" | "actorId" | "transactionalId" | "campaignId" | "loopId" | "eventName" | "timestamp" | "messageId" | `metadata.${string}`>, {}, {}, {}>;
34
34
  doc: import("convex/values").VObject<{
35
+ metadata?: Record<string, any> | undefined;
35
36
  actorId?: string | undefined;
36
37
  transactionalId?: string | undefined;
37
38
  campaignId?: string | undefined;
38
39
  loopId?: string | undefined;
39
40
  eventName?: string | undefined;
40
41
  messageId?: string | undefined;
41
- metadata?: Record<string, any> | undefined;
42
42
  email: string;
43
43
  success: boolean;
44
44
  operationType: "transactional" | "event" | "campaign" | "loop";
@@ -59,7 +59,7 @@ export declare const EmailOperations: {
59
59
  metadata: import("convex/values").VRecord<Record<string, any> | undefined, import("convex/values").VString<string, "required">, import("convex/values").VAny<"required", "required", string>, "optional", string>;
60
60
  _id: import("convex/values").VId<import("convex/values").GenericId<"emailOperations">, "required">;
61
61
  _creationTime: import("convex/values").VFloat64<number, "required">;
62
- }, "required", "email" | "success" | "operationType" | "actorId" | "transactionalId" | "campaignId" | "loopId" | "eventName" | "timestamp" | "messageId" | "metadata" | "_creationTime" | `metadata.${string}` | "_id">;
62
+ }, "required", "email" | "success" | "metadata" | "operationType" | "actorId" | "transactionalId" | "campaignId" | "loopId" | "eventName" | "timestamp" | "messageId" | "_creationTime" | `metadata.${string}` | "_id">;
63
63
  withoutSystemFields: import("zodvex").ConvexValidatorFromZodFieldsAuto<{
64
64
  operationType: z.ZodEnum<{
65
65
  transactional: "transactional";
@@ -1 +1 @@
1
- {"version":3,"file":"emailOperations.d.ts","sourceRoot":"","sources":["../../../src/component/tables/emailOperations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBAay29B,EAAG,SAAS;;;;;;;;uBAAgW,EAAG,SAAS;;CAD3v+B,CAAC"}
1
+ {"version":3,"file":"emailOperations.d.ts","sourceRoot":"","sources":["../../../src/component/tables/emailOperations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAY1B,CAAC"}
package/dist/utils.d.ts CHANGED
@@ -33,19 +33,19 @@ export declare const zq: <A extends import("zod").ZodType | Record<string, impor
33
33
  document: {
34
34
  _id: import("convex/values").GenericId<"emailOperations">;
35
35
  _creationTime: number;
36
+ metadata?: Record<string, any> | undefined;
36
37
  actorId?: string | undefined;
37
38
  transactionalId?: string | undefined;
38
39
  campaignId?: string | undefined;
39
40
  loopId?: string | undefined;
40
41
  eventName?: string | undefined;
41
42
  messageId?: string | undefined;
42
- metadata?: Record<string, any> | undefined;
43
43
  email: string;
44
44
  success: boolean;
45
45
  operationType: "transactional" | "event" | "campaign" | "loop";
46
46
  timestamp: number;
47
47
  };
48
- fieldPaths: ("email" | "success" | "operationType" | "actorId" | "transactionalId" | "campaignId" | "loopId" | "eventName" | "timestamp" | "messageId" | "metadata" | "_creationTime" | `metadata.${string}`) | "_id";
48
+ fieldPaths: ("email" | "success" | "metadata" | "operationType" | "actorId" | "transactionalId" | "campaignId" | "loopId" | "eventName" | "timestamp" | "messageId" | "_creationTime" | `metadata.${string}`) | "_id";
49
49
  indexes: {
50
50
  email: ["email", "timestamp", "_creationTime"];
51
51
  actorId: ["actorId", "timestamp", "_creationTime"];
@@ -95,19 +95,19 @@ export declare const zm: <A extends import("zod").ZodType | Record<string, impor
95
95
  document: {
96
96
  _id: import("convex/values").GenericId<"emailOperations">;
97
97
  _creationTime: number;
98
+ metadata?: Record<string, any> | undefined;
98
99
  actorId?: string | undefined;
99
100
  transactionalId?: string | undefined;
100
101
  campaignId?: string | undefined;
101
102
  loopId?: string | undefined;
102
103
  eventName?: string | undefined;
103
104
  messageId?: string | undefined;
104
- metadata?: Record<string, any> | undefined;
105
105
  email: string;
106
106
  success: boolean;
107
107
  operationType: "transactional" | "event" | "campaign" | "loop";
108
108
  timestamp: number;
109
109
  };
110
- fieldPaths: ("email" | "success" | "operationType" | "actorId" | "transactionalId" | "campaignId" | "loopId" | "eventName" | "timestamp" | "messageId" | "metadata" | "_creationTime" | `metadata.${string}`) | "_id";
110
+ fieldPaths: ("email" | "success" | "metadata" | "operationType" | "actorId" | "transactionalId" | "campaignId" | "loopId" | "eventName" | "timestamp" | "messageId" | "_creationTime" | `metadata.${string}`) | "_id";
111
111
  indexes: {
112
112
  email: ["email", "timestamp", "_creationTime"];
113
113
  actorId: ["actorId", "timestamp", "_creationTime"];
@@ -157,19 +157,19 @@ export declare const za: <A extends import("zod").ZodType | Record<string, impor
157
157
  document: {
158
158
  _id: import("convex/values").GenericId<"emailOperations">;
159
159
  _creationTime: number;
160
+ metadata?: Record<string, any> | undefined;
160
161
  actorId?: string | undefined;
161
162
  transactionalId?: string | undefined;
162
163
  campaignId?: string | undefined;
163
164
  loopId?: string | undefined;
164
165
  eventName?: string | undefined;
165
166
  messageId?: string | undefined;
166
- metadata?: Record<string, any> | undefined;
167
167
  email: string;
168
168
  success: boolean;
169
169
  operationType: "transactional" | "event" | "campaign" | "loop";
170
170
  timestamp: number;
171
171
  };
172
- fieldPaths: ("email" | "success" | "operationType" | "actorId" | "transactionalId" | "campaignId" | "loopId" | "eventName" | "timestamp" | "messageId" | "metadata" | "_creationTime" | `metadata.${string}`) | "_id";
172
+ fieldPaths: ("email" | "success" | "metadata" | "operationType" | "actorId" | "transactionalId" | "campaignId" | "loopId" | "eventName" | "timestamp" | "messageId" | "_creationTime" | `metadata.${string}`) | "_id";
173
173
  indexes: {
174
174
  email: ["email", "timestamp", "_creationTime"];
175
175
  actorId: ["actorId", "timestamp", "_creationTime"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devwithbobby/loops",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Convex component for integrating with Loops.so email marketing platform",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -70,7 +70,7 @@ export class Loops {
70
70
  console.warn(
71
71
  "API key passed directly via options. " +
72
72
  "For security, use LOOPS_API_KEY environment variable instead. " +
73
- "See ENV_SETUP.md for details.",
73
+ "See README.md for details.",
74
74
  );
75
75
  }
76
76
 
@@ -480,6 +480,18 @@ export class Loops {
480
480
  return await this.countContacts(ctx, args);
481
481
  },
482
482
  }),
483
+ listContacts: queryGeneric({
484
+ args: {
485
+ userGroup: v.optional(v.string()),
486
+ source: v.optional(v.string()),
487
+ subscribed: v.optional(v.boolean()),
488
+ limit: v.optional(v.number()),
489
+ offset: v.optional(v.number()),
490
+ },
491
+ handler: async (ctx, args) => {
492
+ return await this.listContacts(ctx, args);
493
+ },
494
+ }),
483
495
  detectRecipientSpam: queryGeneric({
484
496
  args: {
485
497
  timeWindowMs: v.optional(v.number()),
@@ -22,10 +22,6 @@ import { httpAction } from "./_generated/server";
22
22
 
23
23
  const http = httpRouter();
24
24
 
25
- const allowedOrigin =
26
- process.env.LOOPS_HTTP_ALLOWED_ORIGIN ?? process.env.CLIENT_ORIGIN ?? "*";
27
-
28
-
29
25
  http.route({
30
26
  pathPrefix: "/loops/",
31
27
  method: "OPTIONS",
@@ -117,6 +117,10 @@ export const logEmailOperation = zm({
117
117
  /**
118
118
  * Count contacts in the database
119
119
  * Can filter by audience criteria (userGroup, source, subscribed status)
120
+ *
121
+ * Note: When multiple filters are provided, only one index can be used.
122
+ * Additional filters are applied in-memory, which is efficient for small result sets.
123
+ * For large contact lists with multiple filters, consider using a composite index.
120
124
  */
121
125
  export const countContacts = zq({
122
126
  args: z.object({
@@ -126,7 +130,10 @@ export const countContacts = zq({
126
130
  }),
127
131
  returns: z.number(),
128
132
  handler: async (ctx, args) => {
133
+ // Build query using the most selective index available
134
+ // Priority: userGroup > source > subscribed
129
135
  let contacts: Doc<"contacts">[];
136
+
130
137
  if (args.userGroup !== undefined) {
131
138
  contacts = await ctx.db
132
139
  .query("contacts")
@@ -146,17 +153,32 @@ export const countContacts = zq({
146
153
  contacts = await ctx.db.query("contacts").collect();
147
154
  }
148
155
 
149
- if (args.userGroup !== undefined && contacts) {
150
- contacts = contacts.filter((c) => c.userGroup === args.userGroup);
151
- }
152
- if (args.source !== undefined && contacts) {
153
- contacts = contacts.filter((c) => c.source === args.source);
154
- }
155
- if (args.subscribed !== undefined && contacts) {
156
- contacts = contacts.filter((c) => c.subscribed === args.subscribed);
156
+ // Apply additional filters if multiple criteria were provided
157
+ // This avoids redundant filtering when only one filter was used
158
+ const needsFiltering =
159
+ (args.userGroup !== undefined ? 1 : 0) +
160
+ (args.source !== undefined ? 1 : 0) +
161
+ (args.subscribed !== undefined ? 1 : 0) >
162
+ 1;
163
+
164
+ if (!needsFiltering) {
165
+ return contacts.length;
157
166
  }
158
167
 
159
- return contacts.length;
168
+ const filtered = contacts.filter((c) => {
169
+ if (args.userGroup !== undefined && c.userGroup !== args.userGroup) {
170
+ return false;
171
+ }
172
+ if (args.source !== undefined && c.source !== args.source) {
173
+ return false;
174
+ }
175
+ if (args.subscribed !== undefined && c.subscribed !== args.subscribed) {
176
+ return false;
177
+ }
178
+ return true;
179
+ });
180
+
181
+ return filtered.length;
160
182
  },
161
183
  });
162
184
 
@@ -164,6 +186,9 @@ export const countContacts = zq({
164
186
  * List contacts from the database with pagination
165
187
  * Can filter by audience criteria (userGroup, source, subscribed status)
166
188
  * Returns actual contact data, not just a count
189
+ *
190
+ * Note: When multiple filters are provided, only one index can be used.
191
+ * Additional filters are applied in-memory before pagination.
167
192
  */
168
193
  export const listContacts = zq({
169
194
  args: z.object({
@@ -195,9 +220,9 @@ export const listContacts = zq({
195
220
  hasMore: z.boolean(),
196
221
  }),
197
222
  handler: async (ctx, args) => {
223
+ // Build query using the most selective index available
198
224
  let allContacts: Doc<"contacts">[];
199
225
 
200
- // Get all contacts matching the filters
201
226
  if (args.userGroup !== undefined) {
202
227
  allContacts = await ctx.db
203
228
  .query("contacts")
@@ -217,15 +242,26 @@ export const listContacts = zq({
217
242
  allContacts = await ctx.db.query("contacts").collect();
218
243
  }
219
244
 
220
- // Apply additional filters (for cases where we need to filter by multiple criteria)
221
- if (args.userGroup !== undefined && allContacts) {
222
- allContacts = allContacts.filter((c) => c.userGroup === args.userGroup);
223
- }
224
- if (args.source !== undefined && allContacts) {
225
- allContacts = allContacts.filter((c) => c.source === args.source);
226
- }
227
- if (args.subscribed !== undefined && allContacts) {
228
- allContacts = allContacts.filter((c) => c.subscribed === args.subscribed);
245
+ // Apply additional filters if multiple criteria were provided
246
+ const needsFiltering =
247
+ (args.userGroup !== undefined ? 1 : 0) +
248
+ (args.source !== undefined ? 1 : 0) +
249
+ (args.subscribed !== undefined ? 1 : 0) >
250
+ 1;
251
+
252
+ if (needsFiltering) {
253
+ allContacts = allContacts.filter((c) => {
254
+ if (args.userGroup !== undefined && c.userGroup !== args.userGroup) {
255
+ return false;
256
+ }
257
+ if (args.source !== undefined && c.source !== args.source) {
258
+ return false;
259
+ }
260
+ if (args.subscribed !== undefined && c.subscribed !== args.subscribed) {
261
+ return false;
262
+ }
263
+ return true;
264
+ });
229
265
  }
230
266
 
231
267
  // Sort by createdAt (newest first)
@@ -488,7 +524,7 @@ export const sendEvent = za({
488
524
  returns: z.object({
489
525
  success: z.boolean(),
490
526
  }),
491
- handler: async (_ctx, args) => {
527
+ handler: async (ctx, args) => {
492
528
  const response = await loopsFetch(args.apiKey, "/events/send", {
493
529
  method: "POST",
494
530
  json: {
@@ -501,9 +537,22 @@ export const sendEvent = za({
501
537
  if (!response.ok) {
502
538
  const errorText = await response.text();
503
539
  console.error(`Loops API error [${response.status}]:`, errorText);
540
+ await ctx.runMutation(internalLib.logEmailOperation, {
541
+ operationType: "event",
542
+ email: args.email,
543
+ success: false,
544
+ eventName: args.eventName,
545
+ });
504
546
  throw sanitizeLoopsError(response.status, errorText);
505
547
  }
506
548
 
549
+ await ctx.runMutation(internalLib.logEmailOperation, {
550
+ operationType: "event",
551
+ email: args.email,
552
+ success: true,
553
+ eventName: args.eventName,
554
+ });
555
+
507
556
  return { success: true };
508
557
  },
509
558
  });
@@ -1,24 +0,0 @@
1
- import type { Expand, FunctionArgs, FunctionReference, FunctionReturnType, StorageActionWriter, StorageReader } from "convex/server";
2
- import type { GenericId } from "convex/values";
3
- export type RunQueryCtx = {
4
- runQuery: <Query extends FunctionReference<"query", "internal">>(query: Query, args: FunctionArgs<Query>) => Promise<FunctionReturnType<Query>>;
5
- };
6
- export type RunMutationCtx = RunQueryCtx & {
7
- runMutation: <Mutation extends FunctionReference<"mutation", "internal">>(mutation: Mutation, args: FunctionArgs<Mutation>) => Promise<FunctionReturnType<Mutation>>;
8
- };
9
- export type RunActionCtx = RunMutationCtx & {
10
- runAction<Action extends FunctionReference<"action", "internal">>(action: Action, args: FunctionArgs<Action>): Promise<FunctionReturnType<Action>>;
11
- };
12
- export type ActionCtx = RunActionCtx & {
13
- storage: StorageActionWriter;
14
- };
15
- export type QueryCtx = RunQueryCtx & {
16
- storage: StorageReader;
17
- };
18
- export type OpaqueIds<T> = T extends GenericId<infer _T> ? string : T extends (infer U)[] ? OpaqueIds<U>[] : T extends ArrayBuffer ? ArrayBuffer : T extends object ? {
19
- [K in keyof T]: OpaqueIds<T[K]>;
20
- } : T;
21
- export type UseApi<API> = Expand<{
22
- [mod in keyof API]: API[mod] extends FunctionReference<infer FType, "public", infer FArgs, infer FReturnType, infer FComponentPath> ? FunctionReference<FType, "internal", OpaqueIds<FArgs>, OpaqueIds<FReturnType>, FComponentPath> : UseApi<API[mod]>;
23
- }>;
24
- //# sourceMappingURL=types.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/client/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,MAAM,EACN,YAAY,EACZ,iBAAiB,EACjB,kBAAkB,EAClB,mBAAmB,EACnB,aAAa,EACb,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE/C,MAAM,MAAM,WAAW,GAAG;IACzB,QAAQ,EAAE,CAAC,KAAK,SAAS,iBAAiB,CAAC,OAAO,EAAE,UAAU,CAAC,EAC9D,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,YAAY,CAAC,KAAK,CAAC,KACrB,OAAO,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAC,CAAC;CACxC,CAAC;AACF,MAAM,MAAM,cAAc,GAAG,WAAW,GAAG;IAC1C,WAAW,EAAE,CAAC,QAAQ,SAAS,iBAAiB,CAAC,UAAU,EAAE,UAAU,CAAC,EACvE,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE,YAAY,CAAC,QAAQ,CAAC,KACxB,OAAO,CAAC,kBAAkB,CAAC,QAAQ,CAAC,CAAC,CAAC;CAC3C,CAAC;AACF,MAAM,MAAM,YAAY,GAAG,cAAc,GAAG;IAC3C,SAAS,CAAC,MAAM,SAAS,iBAAiB,CAAC,QAAQ,EAAE,UAAU,CAAC,EAC/D,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,YAAY,CAAC,MAAM,CAAC,GACxB,OAAO,CAAC,kBAAkB,CAAC,MAAM,CAAC,CAAC,CAAC;CACvC,CAAC;AACF,MAAM,MAAM,SAAS,GAAG,YAAY,GAAG;IACtC,OAAO,EAAE,mBAAmB,CAAC;CAC7B,CAAC;AACF,MAAM,MAAM,QAAQ,GAAG,WAAW,GAAG;IACpC,OAAO,EAAE,aAAa,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI,CAAC,SAAS,SAAS,CAAC,MAAM,EAAE,CAAC,GACrD,MAAM,GACN,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,EAAE,GACpB,SAAS,CAAC,CAAC,CAAC,EAAE,GACd,CAAC,SAAS,WAAW,GACpB,WAAW,GACX,CAAC,SAAS,MAAM,GACf;KACC,CAAC,IAAI,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAC/B,GACA,CAAC,CAAC;AAER,MAAM,MAAM,MAAM,CAAC,GAAG,IAAI,MAAM,CAAC;KAC/B,GAAG,IAAI,MAAM,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,SAAS,iBAAiB,CACrD,MAAM,KAAK,EACX,QAAQ,EACR,MAAM,KAAK,EACX,MAAM,WAAW,EACjB,MAAM,cAAc,CACpB,GACE,iBAAiB,CACjB,KAAK,EACL,UAAU,EACV,SAAS,CAAC,KAAK,CAAC,EAChB,SAAS,CAAC,WAAW,CAAC,EACtB,cAAc,CACd,GACA,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;CACnB,CAAC,CAAC"}
File without changes