@nordsym/apiclaw 1.3.6 → 1.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +33 -0
  2. package/convex/_generated/api.d.ts +12 -0
  3. package/convex/billing.ts +651 -216
  4. package/convex/crons.ts +17 -0
  5. package/convex/email.ts +135 -82
  6. package/convex/feedback.ts +265 -0
  7. package/convex/http.ts +80 -4
  8. package/convex/logs.ts +287 -0
  9. package/convex/providerKeys.ts +209 -0
  10. package/convex/providers.ts +18 -0
  11. package/convex/schema.ts +115 -0
  12. package/convex/stripeActions.ts +512 -0
  13. package/convex/webhooks.ts +494 -0
  14. package/convex/workspaces.ts +74 -1
  15. package/dist/index.js +178 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/metered.d.ts +62 -0
  18. package/dist/metered.d.ts.map +1 -0
  19. package/dist/metered.js +81 -0
  20. package/dist/metered.js.map +1 -0
  21. package/dist/stripe.d.ts +62 -0
  22. package/dist/stripe.d.ts.map +1 -1
  23. package/dist/stripe.js +212 -0
  24. package/dist/stripe.js.map +1 -1
  25. package/docs/PRD-final-polish.md +117 -0
  26. package/docs/PRD-mobile-responsive.md +56 -0
  27. package/docs/PRD-navigation-expansion.md +295 -0
  28. package/docs/PRD-stripe-billing.md +312 -0
  29. package/docs/PRD-workspace-cleanup.md +200 -0
  30. package/landing/src/app/api/billing/checkout/route.ts +109 -0
  31. package/landing/src/app/api/billing/payment-method/route.ts +118 -0
  32. package/landing/src/app/api/billing/portal/route.ts +64 -0
  33. package/landing/src/app/auth/verify/page.tsx +20 -5
  34. package/landing/src/app/earn/page.tsx +6 -6
  35. package/landing/src/app/login/page.tsx +1 -1
  36. package/landing/src/app/page.tsx +70 -70
  37. package/landing/src/app/providers/dashboard/page.tsx +1 -1
  38. package/landing/src/app/workspace/page.tsx +3497 -535
  39. package/landing/src/components/CheckoutButton.tsx +188 -0
  40. package/landing/src/components/Toast.tsx +84 -0
  41. package/landing/src/lib/stats.json +1 -1
  42. package/landing/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +1 -1
  44. package/src/index.ts +205 -0
  45. package/src/metered.ts +149 -0
  46. package/src/stripe.ts +253 -0
@@ -0,0 +1,17 @@
1
+ import { cronJobs } from "convex/server";
2
+ import { internal } from "./_generated/api";
3
+
4
+ const crons = cronJobs();
5
+
6
+ /**
7
+ * Daily Usage Reporting to Stripe
8
+ * Runs at 00:05 UTC every day
9
+ * Reports metered usage for all active subscriptions
10
+ */
11
+ crons.daily(
12
+ "report-usage-to-stripe",
13
+ { hourUTC: 0, minuteUTC: 5 },
14
+ internal.billing.reportAllUsageToStripe
15
+ );
16
+
17
+ export default crons;
package/convex/email.ts CHANGED
@@ -8,84 +8,68 @@ import { action, internalAction } from "./_generated/server";
8
8
  const EMAIL_FROM = "APIClaw <noreply@apiclaw.nordsym.com>";
9
9
  const APP_URL = "https://apiclaw.nordsym.com";
10
10
 
11
- // Base email wrapper
11
+ // Base email wrapper - using string concat for Convex compatibility
12
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>`;
13
+ const year = new Date().getFullYear();
14
+ return [
15
+ '<!DOCTYPE html>',
16
+ '<html>',
17
+ '<head>',
18
+ '<meta charset="utf-8">',
19
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0">',
20
+ '</head>',
21
+ '<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif;">',
22
+ '<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 20px;">',
23
+ '<tr>',
24
+ '<td align="center">',
25
+ '<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);">',
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
+ '<tr>',
33
+ '<td style="padding: 32px;">',
34
+ content,
35
+ '</td>',
36
+ '</tr>',
37
+ '<tr>',
38
+ '<td style="padding: 24px 32px; background-color: #fafafa; border-top: 1px solid #f0f0f0;">',
39
+ '<p style="margin: 0; font-size: 12px; color: #737373; text-align: center;">',
40
+ '<a href="https://apiclaw.nordsym.com" style="color: #ef4444; text-decoration: none;">APIClaw</a> The API Layer for AI Agents',
41
+ '</p>',
42
+ '<p style="margin: 8px 0 0; font-size: 12px; color: #a3a3a3; text-align: center;">',
43
+ '© ' + year + ' NordSym. Stockholm, Sweden.',
44
+ '</p>',
45
+ '</td>',
46
+ '</tr>',
47
+ '</table>',
48
+ '</td>',
49
+ '</tr>',
50
+ '</table>',
51
+ '</body>',
52
+ '</html>',
53
+ ].join('');
55
54
  }
56
55
 
57
- // Magic link email template
56
+ // Magic link email template - ultra simple for Convex
58
57
  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
- `);
58
+ // Simple inline HTML - no arrays, no template literals
59
+ var html = "<!DOCTYPE html><html><head><meta charset='utf-8'></head>";
60
+ html += "<body style='margin:0;padding:40px;background:#f5f5f5;font-family:Arial,sans-serif;'>";
61
+ html += "<table width='100%' cellpadding='0' cellspacing='0'><tr><td align='center'>";
62
+ html += "<table width='500' cellpadding='0' cellspacing='0' style='background:#fff;border-radius:12px;'>";
63
+ html += "<tr><td style='padding:32px;text-align:center;'>";
64
+ html += "<div style='font-size:48px;'>🦞</div>";
65
+ html += "<h1 style='margin:16px 0;color:#0a0a0a;'>APIClaw</h1>";
66
+ html += "<h2 style='margin:0 0 16px;font-size:20px;color:#0a0a0a;'>An AI Agent Wants to Connect</h2>";
67
+ html += "<p style='margin:0 0 24px;color:#525252;'>Click below to verify your email and activate your workspace.</p>";
68
+ html += "<a href='" + verifyUrl + "' style='display:inline-block;background:#ef4444;color:white;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;'>Verify Email</a>";
69
+ html += "<p style='margin:24px 0 0;font-size:13px;color:#737373;'>Free tier: 50 API calls. This link expires in 1 hour.</p>";
70
+ html += "</td></tr></table>";
71
+ html += "</td></tr></table></body></html>";
72
+ return html;
89
73
  }
90
74
 
91
75
  // Reminder email template
@@ -170,26 +154,45 @@ export const sendMagicLinkEmail = action({
170
154
  throw new Error("RESEND_API_KEY not configured");
171
155
  }
172
156
 
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", {
157
+ var verifyUrl = APP_URL + "/auth/verify?token=" + args.token;
158
+
159
+ // Generate HTML inline (same approach as debug that works)
160
+ var html = "<!DOCTYPE html><html><head><meta charset='utf-8'></head>";
161
+ html += "<body style='margin:0;padding:40px;background:#f5f5f5;font-family:Arial,sans-serif;'>";
162
+ html += "<table width='100%' cellpadding='0' cellspacing='0'><tr><td align='center'>";
163
+ html += "<table width='500' cellpadding='0' cellspacing='0' style='background:#fff;border-radius:12px;'>";
164
+ html += "<tr><td style='padding:32px;text-align:center;'>";
165
+ html += "<div style='font-size:48px;'>🦞</div>";
166
+ html += "<h1 style='margin:16px 0;color:#0a0a0a;'>APIClaw</h1>";
167
+ html += "<h2 style='margin:0 0 16px;font-size:20px;color:#0a0a0a;'>An AI Agent Wants to Connect</h2>";
168
+ html += "<p style='margin:0 0 24px;color:#525252;'>Click below to verify your email and activate your workspace.</p>";
169
+ html += "<a href='" + verifyUrl + "' style='display:inline-block;background:#ef4444;color:white;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;'>Verify Email</a>";
170
+ html += "<p style='margin:24px 0 0;font-size:13px;color:#737373;'>Free tier: 50 API calls. This link expires in 1 hour.</p>";
171
+ html += "</td></tr></table>";
172
+ html += "</td></tr></table></body></html>";
173
+
174
+ var textContent = "APIClaw - An AI Agent Wants to Connect\n\n";
175
+ textContent += "Click the link: " + verifyUrl + "\n\n";
176
+ textContent += "Free tier: 50 API calls. Expires in 1 hour.";
177
+
178
+ var response = await fetch("https://api.resend.com/emails", {
177
179
  method: "POST",
178
180
  headers: {
179
- "Authorization": `Bearer ${RESEND_API_KEY}`,
181
+ "Authorization": "Bearer " + RESEND_API_KEY,
180
182
  "Content-Type": "application/json",
181
183
  },
182
184
  body: JSON.stringify({
183
185
  from: EMAIL_FROM,
184
186
  to: args.email,
185
187
  subject: "🦞 An AI Agent Wants to Connect — Verify Your Email",
186
- html,
188
+ html: html,
189
+ text: textContent,
187
190
  }),
188
191
  });
189
192
 
190
193
  if (!response.ok) {
191
- const error = await response.text();
192
- throw new Error(`Failed to send email: ${error}`);
194
+ var errorText = await response.text();
195
+ throw new Error("Failed to send email: " + errorText);
193
196
  }
194
197
 
195
198
  return { success: true };
@@ -274,3 +277,53 @@ export const sendLimitReachedEmail = action({
274
277
  return { success: true };
275
278
  },
276
279
  });
280
+
281
+ // Debug: Test email template generation
282
+ export const debugEmailTemplate = action({
283
+ args: { email: v.string() },
284
+ handler: async (ctx, { email }) => {
285
+ const RESEND_API_KEY = process.env.RESEND_API_KEY;
286
+ const testUrl = "https://apiclaw.nordsym.com/auth/verify?token=DEBUG_TEST";
287
+
288
+ // Generate HTML using the template
289
+ var html = "<!DOCTYPE html><html><head><meta charset='utf-8'></head>";
290
+ html += "<body style='margin:0;padding:40px;background:#f5f5f5;font-family:Arial,sans-serif;'>";
291
+ html += "<table width='100%' cellpadding='0' cellspacing='0'><tr><td align='center'>";
292
+ html += "<table width='500' cellpadding='0' cellspacing='0' style='background:#fff;border-radius:12px;'>";
293
+ html += "<tr><td style='padding:32px;text-align:center;'>";
294
+ html += "<div style='font-size:48px;'>🦞</div>";
295
+ html += "<h1 style='margin:16px 0;color:#0a0a0a;'>APIClaw DEBUG</h1>";
296
+ html += "<h2 style='margin:0 0 16px;font-size:20px;color:#0a0a0a;'>Debug Email Test</h2>";
297
+ html += "<p style='margin:0 0 24px;color:#525252;'>This is a debug test email.</p>";
298
+ html += "<a href='" + testUrl + "' style='display:inline-block;background:#ef4444;color:white;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;'>Test Link</a>";
299
+ html += "</td></tr></table>";
300
+ html += "</td></tr></table></body></html>";
301
+
302
+ console.log("[Debug] HTML length:", html.length);
303
+ console.log("[Debug] HTML:", html);
304
+
305
+ const response = await fetch("https://api.resend.com/emails", {
306
+ method: "POST",
307
+ headers: {
308
+ "Authorization": "Bearer " + RESEND_API_KEY,
309
+ "Content-Type": "application/json",
310
+ },
311
+ body: JSON.stringify({
312
+ from: "APIClaw <noreply@apiclaw.nordsym.com>",
313
+ to: email,
314
+ subject: "DEBUG EMAIL FROM CONVEX",
315
+ html: html,
316
+ }),
317
+ });
318
+
319
+ const result = await response.text();
320
+ console.log("[Debug] Response:", response.status, result);
321
+
322
+ return {
323
+ htmlLength: html.length,
324
+ htmlPreview: html.substring(0, 200),
325
+ resendStatus: response.status,
326
+ resendResult: result
327
+ };
328
+ },
329
+ });
@@ -0,0 +1,265 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "./_generated/server";
3
+
4
+ // ============================================
5
+ // SUBMIT FEEDBACK
6
+ // ============================================
7
+
8
+ export const submitFeedback = mutation({
9
+ args: {
10
+ token: v.string(),
11
+ type: v.union(v.literal("bug"), v.literal("feature"), v.literal("general")),
12
+ content: v.string(),
13
+ },
14
+ handler: async (ctx, args) => {
15
+ // Verify session
16
+ const session = await ctx.db
17
+ .query("agentSessions")
18
+ .withIndex("by_sessionToken", (q) => q.eq("sessionToken", args.token))
19
+ .first();
20
+
21
+ if (!session) {
22
+ throw new Error("Invalid session");
23
+ }
24
+
25
+ // Create feedback entry
26
+ const feedbackId = await ctx.db.insert("feedback", {
27
+ workspaceId: session.workspaceId,
28
+ type: args.type,
29
+ content: args.content,
30
+ votes: 0,
31
+ votedBy: [],
32
+ status: "new",
33
+ createdAt: Date.now(),
34
+ });
35
+
36
+ return { success: true, feedbackId };
37
+ },
38
+ });
39
+
40
+ // ============================================
41
+ // VOTE ON FEEDBACK
42
+ // ============================================
43
+
44
+ export const voteFeedback = mutation({
45
+ args: {
46
+ token: v.string(),
47
+ feedbackId: v.id("feedback"),
48
+ direction: v.union(v.literal("up"), v.literal("down")),
49
+ },
50
+ handler: async (ctx, args) => {
51
+ // Verify session
52
+ const session = await ctx.db
53
+ .query("agentSessions")
54
+ .withIndex("by_sessionToken", (q) => q.eq("sessionToken", args.token))
55
+ .first();
56
+
57
+ if (!session) {
58
+ throw new Error("Invalid session");
59
+ }
60
+
61
+ // Get feedback
62
+ const feedback = await ctx.db.get(args.feedbackId);
63
+ if (!feedback) {
64
+ throw new Error("Feedback not found");
65
+ }
66
+
67
+ const workspaceIdStr = session.workspaceId.toString();
68
+ const hasVoted = feedback.votedBy.includes(workspaceIdStr);
69
+
70
+ // Calculate new vote count and votedBy array
71
+ let newVotes = feedback.votes;
72
+ let newVotedBy = [...feedback.votedBy];
73
+
74
+ if (args.direction === "up") {
75
+ if (hasVoted) {
76
+ // Already voted, remove vote
77
+ newVotes -= 1;
78
+ newVotedBy = newVotedBy.filter((id) => id !== workspaceIdStr);
79
+ } else {
80
+ // Add upvote
81
+ newVotes += 1;
82
+ newVotedBy.push(workspaceIdStr);
83
+ }
84
+ } else {
85
+ // Downvote
86
+ if (hasVoted) {
87
+ // Already voted up, switch to down (remove 2)
88
+ newVotes -= 2;
89
+ newVotedBy = newVotedBy.filter((id) => id !== workspaceIdStr);
90
+ } else {
91
+ // Downvote
92
+ newVotes -= 1;
93
+ }
94
+ }
95
+
96
+ // Update feedback
97
+ await ctx.db.patch(args.feedbackId, {
98
+ votes: newVotes,
99
+ votedBy: newVotedBy,
100
+ });
101
+
102
+ return { success: true, votes: newVotes, hasVoted: newVotedBy.includes(workspaceIdStr) };
103
+ },
104
+ });
105
+
106
+ // ============================================
107
+ // GET FEEDBACK (with filters and sorting)
108
+ // ============================================
109
+
110
+ export const getFeedback = query({
111
+ args: {
112
+ token: v.string(),
113
+ filterType: v.optional(v.union(v.literal("bug"), v.literal("feature"), v.literal("general"))),
114
+ filterStatus: v.optional(v.union(v.literal("new"), v.literal("reviewing"), v.literal("planned"), v.literal("shipped"))),
115
+ sortBy: v.optional(v.union(v.literal("votes"), v.literal("recent"))),
116
+ limit: v.optional(v.number()),
117
+ },
118
+ handler: async (ctx, args) => {
119
+ // Verify session
120
+ const session = await ctx.db
121
+ .query("agentSessions")
122
+ .withIndex("by_sessionToken", (q) => q.eq("sessionToken", args.token))
123
+ .first();
124
+
125
+ if (!session) {
126
+ return { error: "Invalid session" };
127
+ }
128
+
129
+ const workspaceIdStr = session.workspaceId.toString();
130
+ const limit = args.limit || 50;
131
+
132
+ // Get all feedback
133
+ let feedbackList = await ctx.db.query("feedback").collect();
134
+
135
+ // Apply filters
136
+ if (args.filterType) {
137
+ feedbackList = feedbackList.filter((f) => f.type === args.filterType);
138
+ }
139
+ if (args.filterStatus) {
140
+ feedbackList = feedbackList.filter((f) => f.status === args.filterStatus);
141
+ }
142
+
143
+ // Sort
144
+ if (args.sortBy === "votes" || !args.sortBy) {
145
+ feedbackList.sort((a, b) => b.votes - a.votes);
146
+ } else if (args.sortBy === "recent") {
147
+ feedbackList.sort((a, b) => b.createdAt - a.createdAt);
148
+ }
149
+
150
+ // Limit
151
+ feedbackList = feedbackList.slice(0, limit);
152
+
153
+ // Add hasVoted flag for current user
154
+ const result = feedbackList.map((f) => ({
155
+ ...f,
156
+ hasVoted: f.votedBy.includes(workspaceIdStr),
157
+ isOwn: f.workspaceId.toString() === workspaceIdStr,
158
+ }));
159
+
160
+ return { feedback: result };
161
+ },
162
+ });
163
+
164
+ // ============================================
165
+ // GET MY FEEDBACK
166
+ // ============================================
167
+
168
+ export const getMyFeedback = query({
169
+ args: {
170
+ token: v.string(),
171
+ },
172
+ handler: async (ctx, args) => {
173
+ // Verify session
174
+ const session = await ctx.db
175
+ .query("agentSessions")
176
+ .withIndex("by_sessionToken", (q) => q.eq("sessionToken", args.token))
177
+ .first();
178
+
179
+ if (!session) {
180
+ return { error: "Invalid session" };
181
+ }
182
+
183
+ const feedbackList = await ctx.db
184
+ .query("feedback")
185
+ .withIndex("by_workspaceId", (q) => q.eq("workspaceId", session.workspaceId))
186
+ .collect();
187
+
188
+ // Sort by created date (most recent first)
189
+ feedbackList.sort((a, b) => b.createdAt - a.createdAt);
190
+
191
+ return { feedback: feedbackList };
192
+ },
193
+ });
194
+
195
+ // ============================================
196
+ // ADMIN: UPDATE FEEDBACK STATUS
197
+ // ============================================
198
+
199
+ export const updateFeedbackStatus = mutation({
200
+ args: {
201
+ token: v.string(),
202
+ feedbackId: v.id("feedback"),
203
+ status: v.union(v.literal("new"), v.literal("reviewing"), v.literal("planned"), v.literal("shipped")),
204
+ },
205
+ handler: async (ctx, args) => {
206
+ // Verify session
207
+ const session = await ctx.db
208
+ .query("agentSessions")
209
+ .withIndex("by_sessionToken", (q) => q.eq("sessionToken", args.token))
210
+ .first();
211
+
212
+ if (!session) {
213
+ throw new Error("Invalid session");
214
+ }
215
+
216
+ // For now, allow workspace owners to update status
217
+ // In future, add admin check
218
+ const feedback = await ctx.db.get(args.feedbackId);
219
+ if (!feedback) {
220
+ throw new Error("Feedback not found");
221
+ }
222
+
223
+ await ctx.db.patch(args.feedbackId, {
224
+ status: args.status,
225
+ });
226
+
227
+ return { success: true };
228
+ },
229
+ });
230
+
231
+ // ============================================
232
+ // DELETE FEEDBACK (own only)
233
+ // ============================================
234
+
235
+ export const deleteFeedback = mutation({
236
+ args: {
237
+ token: v.string(),
238
+ feedbackId: v.id("feedback"),
239
+ },
240
+ handler: async (ctx, args) => {
241
+ // Verify session
242
+ const session = await ctx.db
243
+ .query("agentSessions")
244
+ .withIndex("by_sessionToken", (q) => q.eq("sessionToken", args.token))
245
+ .first();
246
+
247
+ if (!session) {
248
+ throw new Error("Invalid session");
249
+ }
250
+
251
+ const feedback = await ctx.db.get(args.feedbackId);
252
+ if (!feedback) {
253
+ throw new Error("Feedback not found");
254
+ }
255
+
256
+ // Only allow owner to delete
257
+ if (feedback.workspaceId.toString() !== session.workspaceId.toString()) {
258
+ throw new Error("Not authorized");
259
+ }
260
+
261
+ await ctx.db.delete(args.feedbackId);
262
+
263
+ return { success: true };
264
+ },
265
+ });
package/convex/http.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { httpRouter } from "convex/server";
2
2
  import { httpAction } from "./_generated/server";
3
3
  import { api, internal } from "./_generated/api";
4
+ import {
5
+ createCheckoutSession,
6
+ createPortalSession,
7
+ handleStripeWebhook,
8
+ checkoutOptions,
9
+ portalOptions,
10
+ webhookOptions,
11
+ } from "./stripeActions";
4
12
 
5
13
  const http = httpRouter();
6
14
 
@@ -632,10 +640,35 @@ http.route({
632
640
  fingerprint,
633
641
  });
634
642
 
635
- // Send email
636
- await ctx.runAction(api.email.sendMagicLinkEmail, {
637
- email: email.toLowerCase(),
638
- token: result.token,
643
+ // Send email directly (bypassing action)
644
+ var verifyUrl = "https://apiclaw.nordsym.com/auth/verify?token=" + result.token;
645
+ var html = "<!DOCTYPE html><html><head><meta charset='utf-8'></head>";
646
+ html += "<body style='margin:0;padding:40px;background:#f5f5f5;font-family:Arial,sans-serif;'>";
647
+ html += "<table width='100%' cellpadding='0' cellspacing='0'><tr><td align='center'>";
648
+ html += "<table width='500' cellpadding='0' cellspacing='0' style='background:#fff;border-radius:12px;'>";
649
+ html += "<tr><td style='padding:32px;text-align:center;'>";
650
+ html += "<div style='font-size:48px;'>🦞</div>";
651
+ html += "<h1 style='margin:16px 0;color:#0a0a0a;'>APIClaw</h1>";
652
+ html += "<h2 style='margin:0 0 16px;font-size:20px;color:#0a0a0a;'>An AI Agent Wants to Connect</h2>";
653
+ html += "<p style='margin:0 0 24px;color:#525252;'>Click below to verify your email and activate your workspace.</p>";
654
+ html += "<a href='" + verifyUrl + "' style='display:inline-block;background:#ef4444;color:white;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;'>Verify Email</a>";
655
+ html += "<p style='margin:24px 0 0;font-size:13px;color:#737373;'>Free tier: 50 API calls. This link expires in 1 hour.</p>";
656
+ html += "</td></tr></table>";
657
+ html += "</td></tr></table></body></html>";
658
+
659
+ var RESEND_KEY = process.env.RESEND_API_KEY;
660
+ await fetch("https://api.resend.com/emails", {
661
+ method: "POST",
662
+ headers: {
663
+ "Authorization": "Bearer " + RESEND_KEY,
664
+ "Content-Type": "application/json",
665
+ },
666
+ body: JSON.stringify({
667
+ from: "APIClaw <noreply@apiclaw.nordsym.com>",
668
+ to: email.toLowerCase(),
669
+ subject: "🦞 Verify Your Email — APIClaw",
670
+ html: html,
671
+ }),
639
672
  });
640
673
 
641
674
  return jsonResponse({
@@ -762,3 +795,46 @@ http.route({
762
795
  method: "OPTIONS",
763
796
  handler: httpAction(async () => new Response(null, { headers: corsHeaders })),
764
797
  });
798
+
799
+ // ==============================================
800
+ // STRIPE BILLING ENDPOINTS
801
+ // ==============================================
802
+
803
+ // Create checkout session
804
+ http.route({
805
+ path: "/api/billing/checkout",
806
+ method: "POST",
807
+ handler: createCheckoutSession,
808
+ });
809
+
810
+ http.route({
811
+ path: "/api/billing/checkout",
812
+ method: "OPTIONS",
813
+ handler: checkoutOptions,
814
+ });
815
+
816
+ // Create billing portal session
817
+ http.route({
818
+ path: "/api/billing/portal",
819
+ method: "POST",
820
+ handler: createPortalSession,
821
+ });
822
+
823
+ http.route({
824
+ path: "/api/billing/portal",
825
+ method: "OPTIONS",
826
+ handler: portalOptions,
827
+ });
828
+
829
+ // Stripe webhook handler
830
+ http.route({
831
+ path: "/api/webhooks/stripe",
832
+ method: "POST",
833
+ handler: handleStripeWebhook,
834
+ });
835
+
836
+ http.route({
837
+ path: "/api/webhooks/stripe",
838
+ method: "OPTIONS",
839
+ handler: webhookOptions,
840
+ });