@nordsym/apiclaw 1.3.3 → 1.3.4
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/convex/_generated/api.d.ts +6 -0
- package/convex/billing.ts +341 -0
- package/convex/email.ts +276 -0
- package/convex/http.ts +154 -0
- package/convex/schema.ts +43 -0
- package/convex/workspaces.ts +663 -0
- package/dist/index.js +387 -4
- package/dist/index.js.map +1 -1
- package/dist/session.d.ts +29 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +87 -0
- package/dist/session.js.map +1 -0
- package/docs/PRD-agent-first-billing.md +525 -0
- package/landing/package-lock.json +21 -3
- package/landing/package.json +2 -1
- package/landing/src/app/api/stripe/webhook/route.ts +178 -0
- package/landing/src/app/api/workspace-auth/magic-link/route.ts +84 -0
- package/landing/src/app/api/workspace-auth/session/route.ts +73 -0
- package/landing/src/app/api/workspace-auth/verify/route.ts +57 -0
- package/landing/src/app/auth/verify/page.tsx +292 -0
- package/landing/src/app/dashboard/layout.tsx +22 -0
- package/landing/src/app/dashboard/page.tsx +692 -0
- package/landing/src/app/dashboard/verify/page.tsx +108 -0
- package/landing/src/app/login/page.tsx +204 -0
- package/landing/src/app/upgrade/page.tsx +288 -0
- package/landing/src/lib/stats.json +14 -15
- package/landing/src/middleware.ts +50 -0
- package/landing/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/index.ts +434 -4
- package/src/session.ts +103 -0
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type * as analytics from "../analytics.js";
|
|
12
|
+
import type * as billing from "../billing.js";
|
|
12
13
|
import type * as capabilities from "../capabilities.js";
|
|
13
14
|
import type * as credits from "../credits.js";
|
|
14
15
|
import type * as directCall from "../directCall.js";
|
|
16
|
+
import type * as email from "../email.js";
|
|
15
17
|
import type * as http from "../http.js";
|
|
16
18
|
import type * as providers from "../providers.js";
|
|
17
19
|
import type * as purchases from "../purchases.js";
|
|
@@ -19,6 +21,7 @@ import type * as ratelimit from "../ratelimit.js";
|
|
|
19
21
|
import type * as telemetry from "../telemetry.js";
|
|
20
22
|
import type * as usage from "../usage.js";
|
|
21
23
|
import type * as waitlist from "../waitlist.js";
|
|
24
|
+
import type * as workspaces from "../workspaces.js";
|
|
22
25
|
|
|
23
26
|
import type {
|
|
24
27
|
ApiFromModules,
|
|
@@ -28,9 +31,11 @@ import type {
|
|
|
28
31
|
|
|
29
32
|
declare const fullApi: ApiFromModules<{
|
|
30
33
|
analytics: typeof analytics;
|
|
34
|
+
billing: typeof billing;
|
|
31
35
|
capabilities: typeof capabilities;
|
|
32
36
|
credits: typeof credits;
|
|
33
37
|
directCall: typeof directCall;
|
|
38
|
+
email: typeof email;
|
|
34
39
|
http: typeof http;
|
|
35
40
|
providers: typeof providers;
|
|
36
41
|
purchases: typeof purchases;
|
|
@@ -38,6 +43,7 @@ declare const fullApi: ApiFromModules<{
|
|
|
38
43
|
telemetry: typeof telemetry;
|
|
39
44
|
usage: typeof usage;
|
|
40
45
|
waitlist: typeof waitlist;
|
|
46
|
+
workspaces: typeof workspaces;
|
|
41
47
|
}>;
|
|
42
48
|
|
|
43
49
|
/**
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation, query, action } from "./_generated/server";
|
|
3
|
+
import { api } from "./_generated/api";
|
|
4
|
+
|
|
5
|
+
// Tier limits
|
|
6
|
+
const TIER_LIMITS: Record<string, number> = {
|
|
7
|
+
free: 100, // 100 API calls/month
|
|
8
|
+
pro: 10000, // 10k API calls/month
|
|
9
|
+
enterprise: -1, // unlimited
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// ============================================
|
|
13
|
+
// STRIPE CUSTOMER MANAGEMENT
|
|
14
|
+
// ============================================
|
|
15
|
+
|
|
16
|
+
// Create Stripe customer for workspace
|
|
17
|
+
export const createStripeCustomer = action({
|
|
18
|
+
args: {
|
|
19
|
+
workspaceId: v.id("workspaces"),
|
|
20
|
+
},
|
|
21
|
+
handler: async (ctx, args): Promise<{ success: boolean; customerId?: string; error?: string }> => {
|
|
22
|
+
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
|
|
23
|
+
if (!stripeSecretKey) {
|
|
24
|
+
return { success: false, error: "Stripe not configured" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Get workspace
|
|
28
|
+
const workspace = await ctx.runQuery(api.billing.getWorkspace, {
|
|
29
|
+
workspaceId: args.workspaceId
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!workspace) {
|
|
33
|
+
return { success: false, error: "Workspace not found" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check if already has customer
|
|
37
|
+
if (workspace.stripeCustomerId) {
|
|
38
|
+
return { success: true, customerId: workspace.stripeCustomerId };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// Create Stripe customer
|
|
43
|
+
const response = await fetch("https://api.stripe.com/v1/customers", {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: {
|
|
46
|
+
"Authorization": `Bearer ${stripeSecretKey}`,
|
|
47
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
48
|
+
},
|
|
49
|
+
body: new URLSearchParams({
|
|
50
|
+
email: workspace.email,
|
|
51
|
+
"metadata[workspaceId]": args.workspaceId,
|
|
52
|
+
"metadata[source]": "apiclaw",
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const customer = await response.json() as { id: string; error?: { message: string } };
|
|
57
|
+
|
|
58
|
+
if (!response.ok || customer.error) {
|
|
59
|
+
return { success: false, error: customer.error?.message || "Failed to create customer" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Save customer ID to workspace
|
|
63
|
+
await ctx.runMutation(api.billing.saveStripeCustomerId, {
|
|
64
|
+
workspaceId: args.workspaceId,
|
|
65
|
+
stripeCustomerId: customer.id,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return { success: true, customerId: customer.id };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return { success: false, error: String(error) };
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Internal: Save Stripe customer ID to workspace
|
|
76
|
+
export const saveStripeCustomerId = mutation({
|
|
77
|
+
args: {
|
|
78
|
+
workspaceId: v.id("workspaces"),
|
|
79
|
+
stripeCustomerId: v.string(),
|
|
80
|
+
},
|
|
81
|
+
handler: async (ctx, args) => {
|
|
82
|
+
await ctx.db.patch(args.workspaceId, {
|
|
83
|
+
stripeCustomerId: args.stripeCustomerId,
|
|
84
|
+
updatedAt: Date.now(),
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Query workspace (for actions)
|
|
90
|
+
export const getWorkspace = query({
|
|
91
|
+
args: {
|
|
92
|
+
workspaceId: v.id("workspaces"),
|
|
93
|
+
},
|
|
94
|
+
handler: async (ctx, args) => {
|
|
95
|
+
return await ctx.db.get(args.workspaceId);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ============================================
|
|
100
|
+
// CHECKOUT SESSION (Add Payment Method)
|
|
101
|
+
// ============================================
|
|
102
|
+
|
|
103
|
+
// Create checkout session for subscription
|
|
104
|
+
export const createCheckoutSession = action({
|
|
105
|
+
args: {
|
|
106
|
+
workspaceId: v.id("workspaces"),
|
|
107
|
+
successUrl: v.string(),
|
|
108
|
+
cancelUrl: v.string(),
|
|
109
|
+
},
|
|
110
|
+
handler: async (ctx, args): Promise<{ success: boolean; sessionId?: string; url?: string; error?: string }> => {
|
|
111
|
+
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
|
|
112
|
+
if (!stripeSecretKey) {
|
|
113
|
+
return { success: false, error: "Stripe not configured" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Get workspace
|
|
117
|
+
const workspace = await ctx.runQuery(api.billing.getWorkspace, {
|
|
118
|
+
workspaceId: args.workspaceId
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (!workspace) {
|
|
122
|
+
return { success: false, error: "Workspace not found" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Create customer if needed
|
|
126
|
+
let customerId = workspace.stripeCustomerId;
|
|
127
|
+
if (!customerId) {
|
|
128
|
+
const result = await ctx.runAction(api.billing.createStripeCustomer, {
|
|
129
|
+
workspaceId: args.workspaceId,
|
|
130
|
+
});
|
|
131
|
+
if (!result.success || !result.customerId) {
|
|
132
|
+
return { success: false, error: result.error || "Failed to create customer" };
|
|
133
|
+
}
|
|
134
|
+
customerId = result.customerId;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
// Create checkout session for Pro subscription
|
|
139
|
+
// Using the existing APIClaw Pro price
|
|
140
|
+
const response = await fetch("https://api.stripe.com/v1/checkout/sessions", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: {
|
|
143
|
+
"Authorization": `Bearer ${stripeSecretKey}`,
|
|
144
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
145
|
+
},
|
|
146
|
+
body: new URLSearchParams({
|
|
147
|
+
customer: customerId,
|
|
148
|
+
mode: "subscription",
|
|
149
|
+
"line_items[0][price]": "price_1T1OC2RtJYK3aJTqlJZskgtP", // APIClaw Pro $99/month
|
|
150
|
+
"line_items[0][quantity]": "1",
|
|
151
|
+
success_url: args.successUrl,
|
|
152
|
+
cancel_url: args.cancelUrl,
|
|
153
|
+
"metadata[workspaceId]": args.workspaceId,
|
|
154
|
+
"metadata[type]": "upgrade_pro",
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const session = await response.json() as {
|
|
159
|
+
id: string;
|
|
160
|
+
url: string;
|
|
161
|
+
error?: { message: string }
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
if (!response.ok || session.error) {
|
|
165
|
+
return { success: false, error: session.error?.message || "Failed to create session" };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
success: true,
|
|
170
|
+
sessionId: session.id,
|
|
171
|
+
url: session.url,
|
|
172
|
+
};
|
|
173
|
+
} catch (error) {
|
|
174
|
+
return { success: false, error: String(error) };
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ============================================
|
|
180
|
+
// WORKSPACE UPGRADE
|
|
181
|
+
// ============================================
|
|
182
|
+
|
|
183
|
+
// Upgrade workspace to paid tier
|
|
184
|
+
export const upgradeWorkspace = mutation({
|
|
185
|
+
args: {
|
|
186
|
+
workspaceId: v.id("workspaces"),
|
|
187
|
+
tier: v.optional(v.string()),
|
|
188
|
+
stripeSubscriptionId: v.optional(v.string()),
|
|
189
|
+
},
|
|
190
|
+
handler: async (ctx, args) => {
|
|
191
|
+
const workspace = await ctx.db.get(args.workspaceId);
|
|
192
|
+
if (!workspace) {
|
|
193
|
+
return { success: false, error: "workspace_not_found" };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const newTier = args.tier || "pro";
|
|
197
|
+
const newLimit = TIER_LIMITS[newTier] || TIER_LIMITS.pro;
|
|
198
|
+
|
|
199
|
+
await ctx.db.patch(args.workspaceId, {
|
|
200
|
+
tier: newTier,
|
|
201
|
+
usageLimit: newLimit,
|
|
202
|
+
status: "active",
|
|
203
|
+
updatedAt: Date.now(),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
success: true,
|
|
208
|
+
tier: newTier,
|
|
209
|
+
usageLimit: newLimit,
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Suspend workspace (e.g., payment failed)
|
|
215
|
+
export const suspendWorkspace = mutation({
|
|
216
|
+
args: {
|
|
217
|
+
workspaceId: v.id("workspaces"),
|
|
218
|
+
reason: v.optional(v.string()),
|
|
219
|
+
},
|
|
220
|
+
handler: async (ctx, args) => {
|
|
221
|
+
const workspace = await ctx.db.get(args.workspaceId);
|
|
222
|
+
if (!workspace) {
|
|
223
|
+
return { success: false, error: "workspace_not_found" };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
await ctx.db.patch(args.workspaceId, {
|
|
227
|
+
status: "suspended",
|
|
228
|
+
updatedAt: Date.now(),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return { success: true };
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Get workspace by Stripe customer ID (for webhooks)
|
|
236
|
+
export const getWorkspaceByStripeCustomer = query({
|
|
237
|
+
args: {
|
|
238
|
+
stripeCustomerId: v.string(),
|
|
239
|
+
},
|
|
240
|
+
handler: async (ctx, args) => {
|
|
241
|
+
return await ctx.db
|
|
242
|
+
.query("workspaces")
|
|
243
|
+
.withIndex("by_stripeCustomerId", (q) => q.eq("stripeCustomerId", args.stripeCustomerId))
|
|
244
|
+
.first();
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ============================================
|
|
249
|
+
// USAGE REPORTING
|
|
250
|
+
// ============================================
|
|
251
|
+
|
|
252
|
+
// Report usage to Stripe (for metered billing)
|
|
253
|
+
// Currently APIClaw Pro is flat-rate, but this prepares for metered billing
|
|
254
|
+
export const reportUsage = action({
|
|
255
|
+
args: {
|
|
256
|
+
workspaceId: v.id("workspaces"),
|
|
257
|
+
amount: v.number(),
|
|
258
|
+
description: v.optional(v.string()),
|
|
259
|
+
},
|
|
260
|
+
handler: async (ctx, args): Promise<{ success: boolean; error?: string }> => {
|
|
261
|
+
// Get workspace
|
|
262
|
+
const workspace = await ctx.runQuery(api.billing.getWorkspace, {
|
|
263
|
+
workspaceId: args.workspaceId
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (!workspace) {
|
|
267
|
+
return { success: false, error: "Workspace not found" };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Only track usage for paid tiers
|
|
271
|
+
if (workspace.tier === "free") {
|
|
272
|
+
// Free tier just uses incrementUsage in workspaces.ts
|
|
273
|
+
return { success: true };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// For paid tiers, increment the usage counter
|
|
277
|
+
// Future: Report to Stripe Billing Meter when metered pricing is set up
|
|
278
|
+
await ctx.runMutation(api.billing.incrementWorkspaceUsage, {
|
|
279
|
+
workspaceId: args.workspaceId,
|
|
280
|
+
amount: args.amount,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// TODO: When metered pricing is configured, report to Stripe:
|
|
284
|
+
// await reportToStripeMeter(workspace.stripeCustomerId, args.amount);
|
|
285
|
+
|
|
286
|
+
return { success: true };
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Internal: Increment workspace usage
|
|
291
|
+
export const incrementWorkspaceUsage = mutation({
|
|
292
|
+
args: {
|
|
293
|
+
workspaceId: v.id("workspaces"),
|
|
294
|
+
amount: v.number(),
|
|
295
|
+
},
|
|
296
|
+
handler: async (ctx, args) => {
|
|
297
|
+
const workspace = await ctx.db.get(args.workspaceId);
|
|
298
|
+
if (!workspace) return;
|
|
299
|
+
|
|
300
|
+
await ctx.db.patch(args.workspaceId, {
|
|
301
|
+
usageCount: workspace.usageCount + args.amount,
|
|
302
|
+
updatedAt: Date.now(),
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// ============================================
|
|
308
|
+
// QUERIES
|
|
309
|
+
// ============================================
|
|
310
|
+
|
|
311
|
+
// Get billing status for workspace
|
|
312
|
+
export const getBillingStatus = query({
|
|
313
|
+
args: {
|
|
314
|
+
workspaceId: v.id("workspaces"),
|
|
315
|
+
},
|
|
316
|
+
handler: async (ctx, args) => {
|
|
317
|
+
const workspace = await ctx.db.get(args.workspaceId);
|
|
318
|
+
if (!workspace) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const usageRemaining = workspace.usageLimit > 0
|
|
323
|
+
? workspace.usageLimit - workspace.usageCount
|
|
324
|
+
: -1;
|
|
325
|
+
|
|
326
|
+
const usagePercent = workspace.usageLimit > 0
|
|
327
|
+
? Math.round((workspace.usageCount / workspace.usageLimit) * 100)
|
|
328
|
+
: 0;
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
tier: workspace.tier,
|
|
332
|
+
status: workspace.status,
|
|
333
|
+
usageCount: workspace.usageCount,
|
|
334
|
+
usageLimit: workspace.usageLimit,
|
|
335
|
+
usageRemaining,
|
|
336
|
+
usagePercent,
|
|
337
|
+
hasStripe: !!workspace.stripeCustomerId,
|
|
338
|
+
email: workspace.email,
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
});
|
package/convex/email.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { action, internalAction } from "./_generated/server";
|
|
3
|
+
|
|
4
|
+
// ============================================
|
|
5
|
+
// EMAIL TEMPLATES
|
|
6
|
+
// ============================================
|
|
7
|
+
|
|
8
|
+
const EMAIL_FROM = "APIClaw <noreply@apiclaw.nordsym.com>";
|
|
9
|
+
const APP_URL = "https://apiclaw.nordsym.com";
|
|
10
|
+
|
|
11
|
+
// Base email wrapper
|
|
12
|
+
function wrapEmail(content: string): string {
|
|
13
|
+
return `
|
|
14
|
+
<!DOCTYPE html>
|
|
15
|
+
<html>
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="utf-8">
|
|
18
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
19
|
+
</head>
|
|
20
|
+
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
|
21
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 20px;">
|
|
22
|
+
<tr>
|
|
23
|
+
<td align="center">
|
|
24
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 500px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
|
25
|
+
<!-- Header -->
|
|
26
|
+
<tr>
|
|
27
|
+
<td style="padding: 32px 32px 24px; text-align: center; border-bottom: 1px solid #f0f0f0;">
|
|
28
|
+
<span style="font-size: 48px;">🦞</span>
|
|
29
|
+
<h1 style="margin: 16px 0 0; font-size: 24px; font-weight: 700; color: #0a0a0a;">APIClaw</h1>
|
|
30
|
+
</td>
|
|
31
|
+
</tr>
|
|
32
|
+
<!-- Content -->
|
|
33
|
+
<tr>
|
|
34
|
+
<td style="padding: 32px;">
|
|
35
|
+
${content}
|
|
36
|
+
</td>
|
|
37
|
+
</tr>
|
|
38
|
+
<!-- Footer -->
|
|
39
|
+
<tr>
|
|
40
|
+
<td style="padding: 24px 32px; background-color: #fafafa; border-top: 1px solid #f0f0f0;">
|
|
41
|
+
<p style="margin: 0; font-size: 12px; color: #737373; text-align: center;">
|
|
42
|
+
<a href="https://apiclaw.nordsym.com" style="color: #ef4444; text-decoration: none;">APIClaw</a> — The API Layer for AI Agents
|
|
43
|
+
</p>
|
|
44
|
+
<p style="margin: 8px 0 0; font-size: 12px; color: #a3a3a3; text-align: center;">
|
|
45
|
+
© ${new Date().getFullYear()} NordSym. Stockholm, Sweden.
|
|
46
|
+
</p>
|
|
47
|
+
</td>
|
|
48
|
+
</tr>
|
|
49
|
+
</table>
|
|
50
|
+
</td>
|
|
51
|
+
</tr>
|
|
52
|
+
</table>
|
|
53
|
+
</body>
|
|
54
|
+
</html>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Magic link email template
|
|
58
|
+
function magicLinkEmailTemplate(verifyUrl: string): string {
|
|
59
|
+
return wrapEmail(`
|
|
60
|
+
<h2 style="margin: 0 0 16px; font-size: 20px; font-weight: 600; color: #0a0a0a; text-align: center;">
|
|
61
|
+
An AI Agent Wants to Connect
|
|
62
|
+
</h2>
|
|
63
|
+
|
|
64
|
+
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.6; color: #525252; text-align: center;">
|
|
65
|
+
Click the button below to verify your email and activate your APIClaw workspace.
|
|
66
|
+
Your agent will be able to use APIs immediately.
|
|
67
|
+
</p>
|
|
68
|
+
|
|
69
|
+
<table width="100%" cellpadding="0" cellspacing="0">
|
|
70
|
+
<tr>
|
|
71
|
+
<td align="center" style="padding: 8px 0 24px;">
|
|
72
|
+
<a href="${verifyUrl}" style="display: inline-block; background: #ef4444; color: white; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">
|
|
73
|
+
Verify Email & Activate
|
|
74
|
+
</a>
|
|
75
|
+
</td>
|
|
76
|
+
</tr>
|
|
77
|
+
</table>
|
|
78
|
+
|
|
79
|
+
<div style="background: #fef2f2; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
|
|
80
|
+
<p style="margin: 0; font-size: 14px; color: #991b1b;">
|
|
81
|
+
<strong>⚡ Free tier:</strong> 100 API calls included. No credit card required.
|
|
82
|
+
</p>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<p style="margin: 0; font-size: 13px; color: #737373; text-align: center;">
|
|
86
|
+
This link expires in 1 hour. If you didn't request this, ignore this email.
|
|
87
|
+
</p>
|
|
88
|
+
`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Reminder email template
|
|
92
|
+
function reminderEmailTemplate(verifyUrl: string): string {
|
|
93
|
+
return wrapEmail(`
|
|
94
|
+
<h2 style="margin: 0 0 16px; font-size: 20px; font-weight: 600; color: #0a0a0a; text-align: center;">
|
|
95
|
+
Still Waiting for You 👋
|
|
96
|
+
</h2>
|
|
97
|
+
|
|
98
|
+
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.6; color: #525252; text-align: center;">
|
|
99
|
+
Your AI agent is patiently waiting for you to verify your email.
|
|
100
|
+
Click below to activate your workspace and let it get to work!
|
|
101
|
+
</p>
|
|
102
|
+
|
|
103
|
+
<table width="100%" cellpadding="0" cellspacing="0">
|
|
104
|
+
<tr>
|
|
105
|
+
<td align="center" style="padding: 8px 0 24px;">
|
|
106
|
+
<a href="${verifyUrl}" style="display: inline-block; background: #ef4444; color: white; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">
|
|
107
|
+
Verify & Get Started
|
|
108
|
+
</a>
|
|
109
|
+
</td>
|
|
110
|
+
</tr>
|
|
111
|
+
</table>
|
|
112
|
+
|
|
113
|
+
<p style="margin: 0; font-size: 13px; color: #737373; text-align: center;">
|
|
114
|
+
This link expires in 1 hour.
|
|
115
|
+
</p>
|
|
116
|
+
`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Limit reached email template
|
|
120
|
+
function limitReachedEmailTemplate(upgradeUrl: string): string {
|
|
121
|
+
return wrapEmail(`
|
|
122
|
+
<h2 style="margin: 0 0 16px; font-size: 20px; font-weight: 600; color: #0a0a0a; text-align: center;">
|
|
123
|
+
Free Tier Limit Reached
|
|
124
|
+
</h2>
|
|
125
|
+
|
|
126
|
+
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.6; color: #525252; text-align: center;">
|
|
127
|
+
Your AI agent has used all 100 free API calls. Add a payment method to continue using APIClaw.
|
|
128
|
+
</p>
|
|
129
|
+
|
|
130
|
+
<div style="background: #f5f5f5; border-radius: 8px; padding: 20px; margin-bottom: 24px;">
|
|
131
|
+
<p style="margin: 0 0 8px; font-size: 14px; font-weight: 600; color: #0a0a0a;">Pro Plan — $10/month</p>
|
|
132
|
+
<ul style="margin: 0; padding: 0 0 0 20px; font-size: 14px; color: #525252; line-height: 1.8;">
|
|
133
|
+
<li>10,000 API calls/month</li>
|
|
134
|
+
<li>Priority support</li>
|
|
135
|
+
<li>Usage analytics</li>
|
|
136
|
+
</ul>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<table width="100%" cellpadding="0" cellspacing="0">
|
|
140
|
+
<tr>
|
|
141
|
+
<td align="center" style="padding: 8px 0 24px;">
|
|
142
|
+
<a href="${upgradeUrl}" style="display: inline-block; background: #ef4444; color: white; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">
|
|
143
|
+
Upgrade Now
|
|
144
|
+
</a>
|
|
145
|
+
</td>
|
|
146
|
+
</tr>
|
|
147
|
+
</table>
|
|
148
|
+
|
|
149
|
+
<p style="margin: 0; font-size: 13px; color: #737373; text-align: center;">
|
|
150
|
+
Questions? Reply to this email.
|
|
151
|
+
</p>
|
|
152
|
+
`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ============================================
|
|
156
|
+
// EMAIL SENDING ACTIONS
|
|
157
|
+
// ============================================
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Send magic link email
|
|
161
|
+
*/
|
|
162
|
+
export const sendMagicLinkEmail = action({
|
|
163
|
+
args: {
|
|
164
|
+
email: v.string(),
|
|
165
|
+
token: v.string(),
|
|
166
|
+
},
|
|
167
|
+
handler: async (ctx, args) => {
|
|
168
|
+
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
|
169
|
+
if (!RESEND_API_KEY) {
|
|
170
|
+
throw new Error("RESEND_API_KEY not configured");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const verifyUrl = `${APP_URL}/auth/verify?token=${args.token}`;
|
|
174
|
+
const html = magicLinkEmailTemplate(verifyUrl);
|
|
175
|
+
|
|
176
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: {
|
|
179
|
+
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
|
180
|
+
"Content-Type": "application/json",
|
|
181
|
+
},
|
|
182
|
+
body: JSON.stringify({
|
|
183
|
+
from: EMAIL_FROM,
|
|
184
|
+
to: args.email,
|
|
185
|
+
subject: "🦞 An AI Agent Wants to Connect — Verify Your Email",
|
|
186
|
+
html,
|
|
187
|
+
}),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
const error = await response.text();
|
|
192
|
+
throw new Error(`Failed to send email: ${error}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { success: true };
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Send reminder email
|
|
201
|
+
*/
|
|
202
|
+
export const sendReminderEmail = action({
|
|
203
|
+
args: {
|
|
204
|
+
email: v.string(),
|
|
205
|
+
token: v.string(),
|
|
206
|
+
},
|
|
207
|
+
handler: async (ctx, args) => {
|
|
208
|
+
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
|
209
|
+
if (!RESEND_API_KEY) {
|
|
210
|
+
throw new Error("RESEND_API_KEY not configured");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const verifyUrl = `${APP_URL}/auth/verify?token=${args.token}`;
|
|
214
|
+
const html = reminderEmailTemplate(verifyUrl);
|
|
215
|
+
|
|
216
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: {
|
|
219
|
+
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
|
220
|
+
"Content-Type": "application/json",
|
|
221
|
+
},
|
|
222
|
+
body: JSON.stringify({
|
|
223
|
+
from: EMAIL_FROM,
|
|
224
|
+
to: args.email,
|
|
225
|
+
subject: "🦞 Your Agent is Still Waiting — Verify Your Email",
|
|
226
|
+
html,
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
const error = await response.text();
|
|
232
|
+
throw new Error(`Failed to send email: ${error}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { success: true };
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Send limit reached email
|
|
241
|
+
*/
|
|
242
|
+
export const sendLimitReachedEmail = action({
|
|
243
|
+
args: {
|
|
244
|
+
email: v.string(),
|
|
245
|
+
},
|
|
246
|
+
handler: async (ctx, args) => {
|
|
247
|
+
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
|
248
|
+
if (!RESEND_API_KEY) {
|
|
249
|
+
throw new Error("RESEND_API_KEY not configured");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const upgradeUrl = `${APP_URL}/upgrade`;
|
|
253
|
+
const html = limitReachedEmailTemplate(upgradeUrl);
|
|
254
|
+
|
|
255
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
256
|
+
method: "POST",
|
|
257
|
+
headers: {
|
|
258
|
+
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
|
259
|
+
"Content-Type": "application/json",
|
|
260
|
+
},
|
|
261
|
+
body: JSON.stringify({
|
|
262
|
+
from: EMAIL_FROM,
|
|
263
|
+
to: args.email,
|
|
264
|
+
subject: "🦞 Free Tier Limit Reached — Upgrade to Continue",
|
|
265
|
+
html,
|
|
266
|
+
}),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (!response.ok) {
|
|
270
|
+
const error = await response.text();
|
|
271
|
+
throw new Error(`Failed to send email: ${error}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { success: true };
|
|
275
|
+
},
|
|
276
|
+
});
|