@niama/loops 0.2.0

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 (74) hide show
  1. package/README.md +506 -0
  2. package/dist/client/index.d.ts +510 -0
  3. package/dist/client/index.d.ts.map +1 -0
  4. package/dist/client/index.js +464 -0
  5. package/dist/component/_generated/api.d.ts +232 -0
  6. package/dist/component/_generated/api.d.ts.map +1 -0
  7. package/dist/component/_generated/api.js +30 -0
  8. package/dist/component/_generated/component.d.ts +245 -0
  9. package/dist/component/_generated/component.d.ts.map +1 -0
  10. package/dist/component/_generated/component.js +9 -0
  11. package/dist/component/_generated/dataModel.d.ts +46 -0
  12. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  13. package/dist/component/_generated/dataModel.js +10 -0
  14. package/dist/component/_generated/server.d.ts +121 -0
  15. package/dist/component/_generated/server.d.ts.map +1 -0
  16. package/dist/component/_generated/server.js +77 -0
  17. package/dist/component/actions.d.ts +159 -0
  18. package/dist/component/actions.d.ts.map +1 -0
  19. package/dist/component/actions.js +468 -0
  20. package/dist/component/aggregates.d.ts +42 -0
  21. package/dist/component/aggregates.d.ts.map +1 -0
  22. package/dist/component/aggregates.js +54 -0
  23. package/dist/component/convex.config.d.ts +3 -0
  24. package/dist/component/convex.config.d.ts.map +1 -0
  25. package/dist/component/convex.config.js +5 -0
  26. package/dist/component/helpers.d.ts +16 -0
  27. package/dist/component/helpers.d.ts.map +1 -0
  28. package/dist/component/helpers.js +98 -0
  29. package/dist/component/http.d.ts +3 -0
  30. package/dist/component/http.d.ts.map +1 -0
  31. package/dist/component/http.js +208 -0
  32. package/dist/component/mutations.d.ts +55 -0
  33. package/dist/component/mutations.d.ts.map +1 -0
  34. package/dist/component/mutations.js +167 -0
  35. package/dist/component/queries.d.ts +171 -0
  36. package/dist/component/queries.d.ts.map +1 -0
  37. package/dist/component/queries.js +516 -0
  38. package/dist/component/schema.d.ts +63 -0
  39. package/dist/component/schema.d.ts.map +1 -0
  40. package/dist/component/schema.js +16 -0
  41. package/dist/component/tables/contacts.d.ts +16 -0
  42. package/dist/component/tables/contacts.d.ts.map +1 -0
  43. package/dist/component/tables/contacts.js +16 -0
  44. package/dist/component/tables/emailOperations.d.ts +17 -0
  45. package/dist/component/tables/emailOperations.d.ts.map +1 -0
  46. package/dist/component/tables/emailOperations.js +17 -0
  47. package/dist/component/validators.d.ts +338 -0
  48. package/dist/component/validators.d.ts.map +1 -0
  49. package/dist/component/validators.js +167 -0
  50. package/dist/test.d.ts +78 -0
  51. package/dist/test.d.ts.map +1 -0
  52. package/dist/test.js +16 -0
  53. package/dist/types.d.ts +39 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +0 -0
  56. package/package.json +112 -0
  57. package/src/client/index.ts +618 -0
  58. package/src/component/_generated/api.ts +253 -0
  59. package/src/component/_generated/component.ts +291 -0
  60. package/src/component/_generated/dataModel.ts +60 -0
  61. package/src/component/_generated/server.ts +161 -0
  62. package/src/component/actions.ts +556 -0
  63. package/src/component/aggregates.ts +89 -0
  64. package/src/component/convex.config.ts +8 -0
  65. package/src/component/helpers.ts +130 -0
  66. package/src/component/http.ts +236 -0
  67. package/src/component/mutations.ts +192 -0
  68. package/src/component/queries.ts +604 -0
  69. package/src/component/schema.ts +17 -0
  70. package/src/component/tables/contacts.ts +17 -0
  71. package/src/component/tables/emailOperations.ts +23 -0
  72. package/src/component/validators.ts +197 -0
  73. package/src/test.ts +27 -0
  74. package/src/types.ts +62 -0
@@ -0,0 +1,54 @@
1
+ import { TableAggregate } from "@convex-dev/aggregate";
2
+ import { components } from "./_generated/api";
3
+ // Cast components to the expected type for the aggregate library
4
+ // biome-ignore lint/suspicious/noExplicitAny: Component API type mismatch with aggregate library
5
+ const contactAggregateComponent = components.contactAggregate;
6
+ /**
7
+ * Aggregate for counting contacts.
8
+ * Uses userGroup as namespace for efficient filtered counting.
9
+ * Key is null since we only need counts, not ordering.
10
+ */
11
+ export const contactAggregate = new TableAggregate(contactAggregateComponent, {
12
+ namespace: (doc) => doc.userGroup,
13
+ sortKey: () => null,
14
+ });
15
+ /**
16
+ * Insert a contact into the aggregate
17
+ */
18
+ export async function aggregateInsert(ctx, doc) {
19
+ await contactAggregate.insertIfDoesNotExist(ctx, doc);
20
+ }
21
+ /**
22
+ * Delete a contact from the aggregate
23
+ */
24
+ export async function aggregateDelete(ctx, doc) {
25
+ await contactAggregate.deleteIfExists(ctx, doc);
26
+ }
27
+ /**
28
+ * Replace a contact in the aggregate (when userGroup changes)
29
+ */
30
+ export async function aggregateReplace(ctx, oldDoc, newDoc) {
31
+ await contactAggregate.replaceOrInsert(ctx, oldDoc, newDoc);
32
+ }
33
+ /**
34
+ * Count contacts by userGroup namespace
35
+ */
36
+ export async function aggregateCountByUserGroup(ctx, userGroup) {
37
+ return await contactAggregate.count(ctx, { namespace: userGroup });
38
+ }
39
+ /**
40
+ * Count all contacts across all userGroups
41
+ */
42
+ export async function aggregateCountTotal(ctx) {
43
+ let total = 0;
44
+ for await (const namespace of contactAggregate.iterNamespaces(ctx)) {
45
+ total += await contactAggregate.count(ctx, { namespace });
46
+ }
47
+ return total;
48
+ }
49
+ /**
50
+ * Clear and reinitialize the aggregate (for backfill)
51
+ */
52
+ export async function aggregateClear(ctx, namespace) {
53
+ await contactAggregate.clear(ctx, { namespace });
54
+ }
@@ -0,0 +1,3 @@
1
+ declare const component: import("convex/server").ComponentDefinition<any>;
2
+ export default component;
3
+ //# sourceMappingURL=convex.config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"convex.config.d.ts","sourceRoot":"","sources":["../../src/component/convex.config.ts"],"names":[],"mappings":"AAGA,QAAA,MAAM,SAAS,kDAA2B,CAAC;AAI3C,eAAe,SAAS,CAAC"}
@@ -0,0 +1,5 @@
1
+ import aggregate from "@convex-dev/aggregate/convex.config";
2
+ import { defineComponent } from "convex/server";
3
+ const component = defineComponent("loops");
4
+ component.use(aggregate, { name: "contactAggregate" });
5
+ export default component;
@@ -0,0 +1,16 @@
1
+ import type { HeadersInitParam } from "../types";
2
+ export declare const LOOPS_API_BASE_URL = "https://app.loops.so/api/v1";
3
+ export declare const sanitizeLoopsError: (status: number, _errorText: string) => Error;
4
+ export type LoopsRequestInit = Omit<RequestInit, "body"> & {
5
+ json?: unknown;
6
+ };
7
+ export declare const loopsFetch: (apiKey: string, path: string, init?: LoopsRequestInit) => Promise<Response>;
8
+ export declare const buildCorsHeaders: (extra?: HeadersInitParam) => Headers;
9
+ export declare const jsonResponse: (data: unknown, init?: ResponseInit) => Response;
10
+ export declare const emptyResponse: (init?: ResponseInit) => Response;
11
+ export declare const readJsonBody: <T>(request: Request) => Promise<T>;
12
+ export declare const booleanFromQuery: (value: string | null) => boolean | undefined;
13
+ export declare const numberFromQuery: (value: string | null, fallback: number) => number;
14
+ export declare const requireLoopsApiKey: () => string;
15
+ export declare const respondError: (error: unknown) => Response;
16
+ //# sourceMappingURL=helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../src/component/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,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"}
@@ -0,0 +1,98 @@
1
+ const allowedOrigin = process.env.CONVEX_URL ??
2
+ process.env.NEXT_PUBLIC_CONVEX_URL ??
3
+ process.env.CONVEX_SITE_URL ??
4
+ process.env.NEXT_PUBLIC_CONVEX_SITE_URL ??
5
+ process.env.LOOPS_HTTP_ALLOWED_ORIGIN ??
6
+ process.env.CLIENT_ORIGIN ??
7
+ "*";
8
+ export const LOOPS_API_BASE_URL = "https://app.loops.so/api/v1";
9
+ export const sanitizeLoopsError = (status, _errorText) => {
10
+ if (status === 401 || status === 403) {
11
+ return new Error("Authentication failed. Please check your API key.");
12
+ }
13
+ if (status === 404) {
14
+ return new Error("Resource not found.");
15
+ }
16
+ if (status === 429) {
17
+ return new Error("Rate limit exceeded. Please try again later.");
18
+ }
19
+ if (status >= 500) {
20
+ return new Error("Loops service error. Please try again later.");
21
+ }
22
+ return new Error(`Loops API error (${status}). Please try again.`);
23
+ };
24
+ export const loopsFetch = async (apiKey, path, init = {}) => {
25
+ const { json, ...rest } = init;
26
+ const headers = new Headers(rest.headers ?? {});
27
+ headers.set("Authorization", `Bearer ${apiKey}`);
28
+ if (json !== undefined && !headers.has("Content-Type")) {
29
+ headers.set("Content-Type", "application/json");
30
+ }
31
+ return fetch(`${LOOPS_API_BASE_URL}${path}`, {
32
+ ...rest,
33
+ headers,
34
+ // @ts-expect-error RequestInit in this build doesn't declare body
35
+ body: json !== undefined ? JSON.stringify(json) : rest.body,
36
+ });
37
+ };
38
+ export const buildCorsHeaders = (extra) => {
39
+ const headers = new Headers(extra ?? {});
40
+ headers.set("Access-Control-Allow-Origin", allowedOrigin);
41
+ headers.set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
42
+ headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
43
+ headers.set("Access-Control-Max-Age", "86400");
44
+ headers.set("Vary", "Origin");
45
+ return headers;
46
+ };
47
+ export const jsonResponse = (data, init) => {
48
+ const headers = buildCorsHeaders(init?.headers ?? undefined);
49
+ headers.set("Content-Type", "application/json");
50
+ return new Response(JSON.stringify(data), { ...init, headers });
51
+ };
52
+ export const emptyResponse = (init) => {
53
+ const headers = buildCorsHeaders(init?.headers ?? undefined);
54
+ return new Response(null, { ...init, headers });
55
+ };
56
+ export const readJsonBody = async (request) => {
57
+ try {
58
+ return (await request.json());
59
+ }
60
+ catch (_error) {
61
+ throw new Error("Invalid JSON body");
62
+ }
63
+ };
64
+ export const booleanFromQuery = (value) => {
65
+ if (value === null) {
66
+ return undefined;
67
+ }
68
+ if (value === "true") {
69
+ return true;
70
+ }
71
+ if (value === "false") {
72
+ return false;
73
+ }
74
+ return undefined;
75
+ };
76
+ export const numberFromQuery = (value, fallback) => {
77
+ if (!value) {
78
+ return fallback;
79
+ }
80
+ const parsed = Number.parseInt(value, 10);
81
+ return Number.isNaN(parsed) ? fallback : parsed;
82
+ };
83
+ export const requireLoopsApiKey = () => {
84
+ const apiKey = process.env.LOOPS_API_KEY;
85
+ if (!apiKey) {
86
+ throw new Error("LOOPS_API_KEY environment variable must be set to use the HTTP API.");
87
+ }
88
+ return apiKey;
89
+ };
90
+ export const respondError = (error) => {
91
+ console.error("[loops:http]", error);
92
+ const message = error instanceof Error ? error.message : "Unexpected error";
93
+ const status = error instanceof Error &&
94
+ error.message.includes("LOOPS_API_KEY environment variable")
95
+ ? 500
96
+ : 400;
97
+ return jsonResponse({ error: message }, { status });
98
+ };
@@ -0,0 +1,3 @@
1
+ declare const http: import("convex/server").HttpRouter;
2
+ export default http;
3
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/component/http.ts"],"names":[],"mappings":"AAsBA,QAAA,MAAM,IAAI,oCAAe,CAAC;AAqN1B,eAAe,IAAI,CAAC"}
@@ -0,0 +1,208 @@
1
+ import { httpRouter } from "convex/server";
2
+ import { api } from "./_generated/api";
3
+ import { httpAction } from "./_generated/server";
4
+ import { booleanFromQuery, buildCorsHeaders, emptyResponse, jsonResponse, numberFromQuery, readJsonBody, requireLoopsApiKey, respondError, } from "./helpers";
5
+ const http = httpRouter();
6
+ http.route({
7
+ pathPrefix: "/loops/",
8
+ method: "OPTIONS",
9
+ handler: httpAction(async (_ctx, request) => {
10
+ const headers = buildCorsHeaders();
11
+ const requestedHeaders = request.headers.get("Access-Control-Request-Headers");
12
+ if (requestedHeaders) {
13
+ headers.set("Access-Control-Allow-Headers", requestedHeaders);
14
+ }
15
+ const requestedMethod = request.headers.get("Access-Control-Request-Method");
16
+ if (requestedMethod) {
17
+ headers.set("Access-Control-Allow-Methods", `${requestedMethod},OPTIONS`);
18
+ }
19
+ return new Response(null, { status: 204, headers });
20
+ }),
21
+ });
22
+ http.route({
23
+ path: "/loops/contacts",
24
+ method: "POST",
25
+ handler: httpAction(async (ctx, request) => {
26
+ try {
27
+ const contact = await readJsonBody(request);
28
+ const data = await ctx.runAction(api.actions.addContact, {
29
+ apiKey: requireLoopsApiKey(),
30
+ contact,
31
+ });
32
+ return jsonResponse(data, { status: 201 });
33
+ }
34
+ catch (error) {
35
+ return respondError(error);
36
+ }
37
+ }),
38
+ });
39
+ http.route({
40
+ path: "/loops/contacts",
41
+ method: "PUT",
42
+ handler: httpAction(async (ctx, request) => {
43
+ try {
44
+ const payload = await readJsonBody(request);
45
+ if (!payload.email) {
46
+ throw new Error("email is required");
47
+ }
48
+ const data = await ctx.runAction(api.actions.updateContact, {
49
+ apiKey: requireLoopsApiKey(),
50
+ email: payload.email,
51
+ dataVariables: payload.dataVariables,
52
+ firstName: payload.firstName,
53
+ lastName: payload.lastName,
54
+ userId: payload.userId,
55
+ source: payload.source,
56
+ subscribed: payload.subscribed,
57
+ userGroup: payload.userGroup,
58
+ });
59
+ return jsonResponse(data);
60
+ }
61
+ catch (error) {
62
+ return respondError(error);
63
+ }
64
+ }),
65
+ });
66
+ http.route({
67
+ path: "/loops/contacts",
68
+ method: "GET",
69
+ handler: httpAction(async (ctx, request) => {
70
+ try {
71
+ const url = new URL(request.url);
72
+ const email = url.searchParams.get("email");
73
+ if (email) {
74
+ const data = await ctx.runAction(api.actions.findContact, {
75
+ apiKey: requireLoopsApiKey(),
76
+ email,
77
+ });
78
+ return jsonResponse(data);
79
+ }
80
+ const data = await ctx.runQuery(api.queries.listContacts, {
81
+ userGroup: url.searchParams.get("userGroup") ?? undefined,
82
+ source: url.searchParams.get("source") ?? undefined,
83
+ subscribed: booleanFromQuery(url.searchParams.get("subscribed")),
84
+ limit: numberFromQuery(url.searchParams.get("limit"), 100),
85
+ cursor: url.searchParams.get("cursor") ?? null,
86
+ });
87
+ return jsonResponse(data);
88
+ }
89
+ catch (error) {
90
+ return respondError(error);
91
+ }
92
+ }),
93
+ });
94
+ http.route({
95
+ path: "/loops/contacts",
96
+ method: "DELETE",
97
+ handler: httpAction(async (ctx, request) => {
98
+ try {
99
+ const payload = await readJsonBody(request);
100
+ if (!payload.email) {
101
+ throw new Error("email is required");
102
+ }
103
+ await ctx.runAction(api.actions.deleteContact, {
104
+ apiKey: requireLoopsApiKey(),
105
+ email: payload.email,
106
+ });
107
+ return emptyResponse({ status: 204 });
108
+ }
109
+ catch (error) {
110
+ return respondError(error);
111
+ }
112
+ }),
113
+ });
114
+ http.route({
115
+ path: "/loops/transactional",
116
+ method: "POST",
117
+ handler: httpAction(async (ctx, request) => {
118
+ try {
119
+ const payload = await readJsonBody(request);
120
+ if (!payload.transactionalId) {
121
+ throw new Error("transactionalId is required");
122
+ }
123
+ if (!payload.email) {
124
+ throw new Error("email is required");
125
+ }
126
+ const data = await ctx.runAction(api.actions.sendTransactional, {
127
+ apiKey: requireLoopsApiKey(),
128
+ transactionalId: payload.transactionalId,
129
+ email: payload.email,
130
+ dataVariables: payload.dataVariables,
131
+ idempotencyKey: payload.idempotencyKey,
132
+ });
133
+ return jsonResponse(data);
134
+ }
135
+ catch (error) {
136
+ return respondError(error);
137
+ }
138
+ }),
139
+ });
140
+ http.route({
141
+ path: "/loops/events",
142
+ method: "POST",
143
+ handler: httpAction(async (ctx, request) => {
144
+ try {
145
+ const payload = await readJsonBody(request);
146
+ if (!payload.email) {
147
+ throw new Error("email is required");
148
+ }
149
+ if (!payload.eventName) {
150
+ throw new Error("eventName is required");
151
+ }
152
+ const data = await ctx.runAction(api.actions.sendEvent, {
153
+ apiKey: requireLoopsApiKey(),
154
+ email: payload.email,
155
+ eventName: payload.eventName,
156
+ eventProperties: payload.eventProperties,
157
+ });
158
+ return jsonResponse(data);
159
+ }
160
+ catch (error) {
161
+ return respondError(error);
162
+ }
163
+ }),
164
+ });
165
+ http.route({
166
+ path: "/loops/trigger",
167
+ method: "POST",
168
+ handler: httpAction(async (ctx, request) => {
169
+ try {
170
+ const payload = await readJsonBody(request);
171
+ if (!payload.loopId) {
172
+ throw new Error("loopId is required");
173
+ }
174
+ if (!payload.email) {
175
+ throw new Error("email is required");
176
+ }
177
+ const data = await ctx.runAction(api.actions.triggerLoop, {
178
+ apiKey: requireLoopsApiKey(),
179
+ loopId: payload.loopId,
180
+ email: payload.email,
181
+ dataVariables: payload.dataVariables,
182
+ eventName: payload.eventName,
183
+ });
184
+ return jsonResponse(data);
185
+ }
186
+ catch (error) {
187
+ return respondError(error);
188
+ }
189
+ }),
190
+ });
191
+ http.route({
192
+ path: "/loops/stats",
193
+ method: "GET",
194
+ handler: httpAction(async (ctx, request) => {
195
+ try {
196
+ const url = new URL(request.url);
197
+ const timeWindowMs = numberFromQuery(url.searchParams.get("timeWindowMs"), 86400000);
198
+ const data = await ctx.runQuery(api.queries.getEmailStats, {
199
+ timeWindowMs,
200
+ });
201
+ return jsonResponse(data);
202
+ }
203
+ catch (error) {
204
+ return respondError(error);
205
+ }
206
+ }),
207
+ });
208
+ export default http;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Internal mutation to store/update a contact in the database
3
+ */
4
+ export declare const storeContact: import("convex/server").RegisteredMutation<"internal", {
5
+ firstName?: string | undefined;
6
+ lastName?: string | undefined;
7
+ userId?: string | undefined;
8
+ source?: string | undefined;
9
+ subscribed?: boolean | undefined;
10
+ userGroup?: string | undefined;
11
+ loopsContactId?: string | undefined;
12
+ email: string;
13
+ }, Promise<null>>;
14
+ /**
15
+ * Internal mutation to delete a contact from the database
16
+ */
17
+ export declare const removeContact: import("convex/server").RegisteredMutation<"internal", {
18
+ email: string;
19
+ }, Promise<null>>;
20
+ /**
21
+ * Internal mutation to log an email operation for monitoring
22
+ */
23
+ export declare const logEmailOperation: import("convex/server").RegisteredMutation<"internal", {
24
+ actorId?: string | undefined;
25
+ transactionalId?: string | undefined;
26
+ campaignId?: string | undefined;
27
+ loopId?: string | undefined;
28
+ eventName?: string | undefined;
29
+ messageId?: string | undefined;
30
+ metadata?: Record<string, any> | undefined;
31
+ email: string;
32
+ operationType: "transactional" | "event" | "campaign" | "loop";
33
+ success: boolean;
34
+ }, Promise<null>>;
35
+ /**
36
+ * Backfill the contact aggregate with existing contacts.
37
+ * Run this mutation after upgrading to a version with aggregate support.
38
+ *
39
+ * This processes contacts in batches to avoid timeout issues with large datasets.
40
+ * Call repeatedly with the returned cursor until isDone is true.
41
+ *
42
+ * Usage:
43
+ * 1. First call with clear: true to reset the aggregate
44
+ * 2. Subsequent calls with the returned cursor until isDone is true
45
+ */
46
+ export declare const backfillContactAggregate: import("convex/server").RegisteredMutation<"public", {
47
+ batchSize?: number | undefined;
48
+ clear?: boolean | undefined;
49
+ cursor?: string | null | undefined;
50
+ }, Promise<{
51
+ processed: number;
52
+ cursor: string;
53
+ isDone: boolean;
54
+ }>>;
55
+ //# sourceMappingURL=mutations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mutations.d.ts","sourceRoot":"","sources":["../../src/component/mutations.ts"],"names":[],"mappings":"AAgBA;;GAEG;AACH,eAAO,MAAM,YAAY;;;;;;;;;iBA4DvB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,aAAa;;iBAkBxB,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB;;;;;;;;;;;iBAmC5B,CAAC;AAEH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB;;;;;;;;GAoCnC,CAAC"}
@@ -0,0 +1,167 @@
1
+ import { v } from "convex/values";
2
+ import { paginator } from "convex-helpers/server/pagination";
3
+ import { internalMutation, mutation } from "./_generated/server";
4
+ import { aggregateClear, aggregateDelete, aggregateInsert, aggregateReplace, } from "./aggregates";
5
+ import schema from "./schema";
6
+ import { backfillResponseValidator, operationTypeValidator, } from "./validators";
7
+ /**
8
+ * Internal mutation to store/update a contact in the database
9
+ */
10
+ export const storeContact = internalMutation({
11
+ args: {
12
+ email: v.string(),
13
+ firstName: v.optional(v.string()),
14
+ lastName: v.optional(v.string()),
15
+ userId: v.optional(v.string()),
16
+ source: v.optional(v.string()),
17
+ subscribed: v.optional(v.boolean()),
18
+ userGroup: v.optional(v.string()),
19
+ loopsContactId: v.optional(v.string()),
20
+ },
21
+ returns: v.null(),
22
+ handler: async (ctx, args) => {
23
+ const now = Date.now();
24
+ const existing = await ctx.db
25
+ .query("contacts")
26
+ .withIndex("email", (q) => q.eq("email", args.email))
27
+ .unique();
28
+ if (existing) {
29
+ // Update the contact
30
+ await ctx.db.patch(existing._id, {
31
+ firstName: args.firstName,
32
+ lastName: args.lastName,
33
+ userId: args.userId,
34
+ source: args.source,
35
+ subscribed: args.subscribed ?? existing.subscribed,
36
+ userGroup: args.userGroup,
37
+ loopsContactId: args.loopsContactId,
38
+ updatedAt: now,
39
+ });
40
+ // Get the updated document and update aggregate if userGroup changed
41
+ const updated = await ctx.db.get(existing._id);
42
+ if (updated && existing.userGroup !== updated.userGroup) {
43
+ await aggregateReplace(ctx, existing, updated);
44
+ }
45
+ }
46
+ else {
47
+ // Insert new contact
48
+ const id = await ctx.db.insert("contacts", {
49
+ email: args.email,
50
+ firstName: args.firstName,
51
+ lastName: args.lastName,
52
+ userId: args.userId,
53
+ source: args.source,
54
+ subscribed: args.subscribed ?? true,
55
+ userGroup: args.userGroup,
56
+ loopsContactId: args.loopsContactId,
57
+ createdAt: now,
58
+ updatedAt: now,
59
+ });
60
+ // Add to aggregate for counting
61
+ const newDoc = await ctx.db.get(id);
62
+ if (newDoc) {
63
+ await aggregateInsert(ctx, newDoc);
64
+ }
65
+ }
66
+ return null;
67
+ },
68
+ });
69
+ /**
70
+ * Internal mutation to delete a contact from the database
71
+ */
72
+ export const removeContact = internalMutation({
73
+ args: {
74
+ email: v.string(),
75
+ },
76
+ returns: v.null(),
77
+ handler: async (ctx, args) => {
78
+ const existing = await ctx.db
79
+ .query("contacts")
80
+ .withIndex("email", (q) => q.eq("email", args.email))
81
+ .unique();
82
+ if (existing) {
83
+ // Remove from aggregate first (before deleting the document)
84
+ await aggregateDelete(ctx, existing);
85
+ await ctx.db.delete(existing._id);
86
+ }
87
+ return null;
88
+ },
89
+ });
90
+ /**
91
+ * Internal mutation to log an email operation for monitoring
92
+ */
93
+ export const logEmailOperation = internalMutation({
94
+ args: {
95
+ operationType: operationTypeValidator,
96
+ email: v.string(),
97
+ actorId: v.optional(v.string()),
98
+ transactionalId: v.optional(v.string()),
99
+ campaignId: v.optional(v.string()),
100
+ loopId: v.optional(v.string()),
101
+ eventName: v.optional(v.string()),
102
+ success: v.boolean(),
103
+ messageId: v.optional(v.string()),
104
+ metadata: v.optional(v.record(v.string(), v.any())),
105
+ },
106
+ returns: v.null(),
107
+ handler: async (ctx, args) => {
108
+ const operationData = {
109
+ operationType: args.operationType,
110
+ email: args.email,
111
+ timestamp: Date.now(),
112
+ success: args.success,
113
+ actorId: args.actorId,
114
+ transactionalId: args.transactionalId,
115
+ campaignId: args.campaignId,
116
+ loopId: args.loopId,
117
+ eventName: args.eventName,
118
+ messageId: args.messageId,
119
+ metadata: args.metadata,
120
+ };
121
+ await ctx.db.insert("emailOperations", operationData);
122
+ return null;
123
+ },
124
+ });
125
+ /**
126
+ * Backfill the contact aggregate with existing contacts.
127
+ * Run this mutation after upgrading to a version with aggregate support.
128
+ *
129
+ * This processes contacts in batches to avoid timeout issues with large datasets.
130
+ * Call repeatedly with the returned cursor until isDone is true.
131
+ *
132
+ * Usage:
133
+ * 1. First call with clear: true to reset the aggregate
134
+ * 2. Subsequent calls with the returned cursor until isDone is true
135
+ */
136
+ export const backfillContactAggregate = mutation({
137
+ args: {
138
+ cursor: v.optional(v.union(v.string(), v.null())),
139
+ batchSize: v.optional(v.number()),
140
+ clear: v.optional(v.boolean()),
141
+ },
142
+ returns: backfillResponseValidator,
143
+ handler: async (ctx, args) => {
144
+ const batchSize = args.batchSize ?? 100;
145
+ // Clear aggregate on first call if requested
146
+ if (args.clear && !args.cursor) {
147
+ await aggregateClear(ctx);
148
+ }
149
+ const paginationOpts = {
150
+ cursor: args.cursor ?? null,
151
+ numItems: Math.min(Math.max(1, batchSize), 500),
152
+ };
153
+ const result = await paginator(ctx.db, schema)
154
+ .query("contacts")
155
+ .order("asc")
156
+ .paginate(paginationOpts);
157
+ // Insert each contact into the aggregate
158
+ for (const contact of result.page) {
159
+ await aggregateInsert(ctx, contact);
160
+ }
161
+ return {
162
+ processed: result.page.length,
163
+ cursor: result.continueCursor,
164
+ isDone: result.isDone,
165
+ };
166
+ },
167
+ });