@fatagnus/convex-feedback 0.2.6 → 0.2.8
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 +346 -4
- package/dist/index.d.ts +17 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/react/BugReportButton.d.ts.map +1 -1
- package/dist/react/BugReportButton.js +7 -3
- package/dist/react/BugReportButton.js.map +1 -1
- package/package.json +12 -5
- package/src/convex/_generated/api.ts +2923 -0
- package/src/convex/_generated/component.ts +892 -0
- package/src/convex/_generated/dataModel.ts +60 -0
- package/src/convex/_generated/server.ts +161 -0
- package/src/convex/agents/bugReportAgent.ts +37 -3
- package/src/convex/agents/feedbackAgent.ts +37 -3
- package/src/convex/agents/feedbackInterviewAgent.ts +6 -12
- package/src/convex/agents/index.ts +12 -5
- package/src/convex/apiKeys.test.ts +79 -0
- package/src/convex/apiKeys.ts +223 -0
- package/src/convex/bugReports.ts +148 -9
- package/src/convex/emails/bugReportEmails.ts +6 -3
- package/src/convex/emails/feedbackEmails.ts +6 -3
- package/src/convex/feedback.ts +155 -8
- package/src/convex/http.test.ts +76 -0
- package/src/convex/http.ts +630 -0
- package/src/convex/index.ts +11 -0
- package/src/convex/prompts.test.ts +185 -0
- package/src/convex/prompts.ts +605 -0
- package/src/convex/schema.ts +52 -2
- package/src/convex/ticketNumbers.ts +4 -0
- package/src/convex/tsconfig.json +24 -0
- package/src/index.ts +49 -1
- package/src/types.ts +38 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation, query, internalQuery } from "./_generated/server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate a secure random API key
|
|
6
|
+
* Format: fb_<32 random hex chars> = 35 chars total
|
|
7
|
+
*/
|
|
8
|
+
function generateApiKey(): string {
|
|
9
|
+
const randomBytes = new Uint8Array(16);
|
|
10
|
+
crypto.getRandomValues(randomBytes);
|
|
11
|
+
const hex = Array.from(randomBytes)
|
|
12
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
13
|
+
.join("");
|
|
14
|
+
return `fb_${hex}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hash an API key using SHA-256
|
|
19
|
+
*/
|
|
20
|
+
async function hashApiKey(key: string): Promise<string> {
|
|
21
|
+
const encoder = new TextEncoder();
|
|
22
|
+
const data = encoder.encode(key);
|
|
23
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
24
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
25
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a new API key.
|
|
30
|
+
* Returns the full key - this is the only time it will be visible!
|
|
31
|
+
*/
|
|
32
|
+
export const create = mutation({
|
|
33
|
+
args: {
|
|
34
|
+
name: v.string(),
|
|
35
|
+
expiresAt: v.optional(v.number()),
|
|
36
|
+
},
|
|
37
|
+
returns: v.object({
|
|
38
|
+
key: v.string(),
|
|
39
|
+
keyPrefix: v.string(),
|
|
40
|
+
name: v.string(),
|
|
41
|
+
expiresAt: v.union(v.number(), v.null()),
|
|
42
|
+
createdAt: v.number(),
|
|
43
|
+
}),
|
|
44
|
+
handler: async (ctx, args) => {
|
|
45
|
+
const key = generateApiKey();
|
|
46
|
+
const keyHash = await hashApiKey(key);
|
|
47
|
+
const keyPrefix = key.slice(0, 11); // "fb_" + first 8 chars
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
|
|
50
|
+
await ctx.db.insert("apiKeys", {
|
|
51
|
+
keyHash,
|
|
52
|
+
keyPrefix,
|
|
53
|
+
name: args.name,
|
|
54
|
+
expiresAt: args.expiresAt,
|
|
55
|
+
isRevoked: false,
|
|
56
|
+
createdAt: now,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
key, // Full key - only returned on creation!
|
|
61
|
+
keyPrefix,
|
|
62
|
+
name: args.name,
|
|
63
|
+
expiresAt: args.expiresAt ?? null,
|
|
64
|
+
createdAt: now,
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* List all API keys (without the actual key values).
|
|
71
|
+
*/
|
|
72
|
+
export const list = query({
|
|
73
|
+
args: {},
|
|
74
|
+
returns: v.array(
|
|
75
|
+
v.object({
|
|
76
|
+
_id: v.id("apiKeys"),
|
|
77
|
+
keyPrefix: v.string(),
|
|
78
|
+
name: v.string(),
|
|
79
|
+
expiresAt: v.union(v.number(), v.null()),
|
|
80
|
+
isRevoked: v.boolean(),
|
|
81
|
+
revokedAt: v.union(v.number(), v.null()),
|
|
82
|
+
createdAt: v.number(),
|
|
83
|
+
isExpired: v.boolean(),
|
|
84
|
+
})
|
|
85
|
+
),
|
|
86
|
+
handler: async (ctx) => {
|
|
87
|
+
const keys = await ctx.db.query("apiKeys").collect();
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
|
|
90
|
+
return keys.map((k) => ({
|
|
91
|
+
_id: k._id,
|
|
92
|
+
keyPrefix: k.keyPrefix,
|
|
93
|
+
name: k.name,
|
|
94
|
+
expiresAt: k.expiresAt ?? null,
|
|
95
|
+
isRevoked: k.isRevoked,
|
|
96
|
+
revokedAt: k.revokedAt ?? null,
|
|
97
|
+
createdAt: k.createdAt,
|
|
98
|
+
isExpired: k.expiresAt !== undefined && k.expiresAt < now,
|
|
99
|
+
}));
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Revoke an API key by its prefix.
|
|
105
|
+
*/
|
|
106
|
+
export const revoke = mutation({
|
|
107
|
+
args: {
|
|
108
|
+
keyPrefix: v.string(),
|
|
109
|
+
},
|
|
110
|
+
returns: v.boolean(),
|
|
111
|
+
handler: async (ctx, args) => {
|
|
112
|
+
const key = await ctx.db
|
|
113
|
+
.query("apiKeys")
|
|
114
|
+
.withIndex("by_key_prefix", (q) => q.eq("keyPrefix", args.keyPrefix))
|
|
115
|
+
.unique();
|
|
116
|
+
|
|
117
|
+
if (!key) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await ctx.db.patch(key._id, {
|
|
122
|
+
isRevoked: true,
|
|
123
|
+
revokedAt: Date.now(),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return true;
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Rotate an API key - revokes the old key and creates a new one.
|
|
132
|
+
* Returns the new key (only time it will be visible!).
|
|
133
|
+
*/
|
|
134
|
+
export const rotate = mutation({
|
|
135
|
+
args: {
|
|
136
|
+
keyPrefix: v.string(),
|
|
137
|
+
name: v.optional(v.string()),
|
|
138
|
+
expiresAt: v.optional(v.number()),
|
|
139
|
+
},
|
|
140
|
+
returns: v.union(
|
|
141
|
+
v.object({
|
|
142
|
+
key: v.string(),
|
|
143
|
+
keyPrefix: v.string(),
|
|
144
|
+
name: v.string(),
|
|
145
|
+
expiresAt: v.union(v.number(), v.null()),
|
|
146
|
+
createdAt: v.number(),
|
|
147
|
+
}),
|
|
148
|
+
v.null()
|
|
149
|
+
),
|
|
150
|
+
handler: async (ctx, args) => {
|
|
151
|
+
// Find and revoke the old key
|
|
152
|
+
const oldKey = await ctx.db
|
|
153
|
+
.query("apiKeys")
|
|
154
|
+
.withIndex("by_key_prefix", (q) => q.eq("keyPrefix", args.keyPrefix))
|
|
155
|
+
.unique();
|
|
156
|
+
|
|
157
|
+
if (!oldKey) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Revoke old key
|
|
162
|
+
await ctx.db.patch(oldKey._id, {
|
|
163
|
+
isRevoked: true,
|
|
164
|
+
revokedAt: Date.now(),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Create new key
|
|
168
|
+
const newKey = generateApiKey();
|
|
169
|
+
const keyHash = await hashApiKey(newKey);
|
|
170
|
+
const keyPrefix = newKey.slice(0, 11);
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const name = args.name ?? oldKey.name;
|
|
173
|
+
|
|
174
|
+
await ctx.db.insert("apiKeys", {
|
|
175
|
+
keyHash,
|
|
176
|
+
keyPrefix,
|
|
177
|
+
name,
|
|
178
|
+
expiresAt: args.expiresAt,
|
|
179
|
+
isRevoked: false,
|
|
180
|
+
createdAt: now,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
key: newKey,
|
|
185
|
+
keyPrefix,
|
|
186
|
+
name,
|
|
187
|
+
expiresAt: args.expiresAt ?? null,
|
|
188
|
+
createdAt: now,
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Validate an API key (internal use for HTTP endpoint).
|
|
195
|
+
* Returns true if valid and not expired/revoked.
|
|
196
|
+
*/
|
|
197
|
+
export const validateKey = internalQuery({
|
|
198
|
+
args: {
|
|
199
|
+
key: v.string(),
|
|
200
|
+
},
|
|
201
|
+
returns: v.boolean(),
|
|
202
|
+
handler: async (ctx, args) => {
|
|
203
|
+
const keyHash = await hashApiKey(args.key);
|
|
204
|
+
const apiKey = await ctx.db
|
|
205
|
+
.query("apiKeys")
|
|
206
|
+
.withIndex("by_key_hash", (q) => q.eq("keyHash", keyHash))
|
|
207
|
+
.unique();
|
|
208
|
+
|
|
209
|
+
if (!apiKey) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (apiKey.isRevoked) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (apiKey.expiresAt !== undefined && apiKey.expiresAt < Date.now()) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return true;
|
|
222
|
+
},
|
|
223
|
+
});
|
package/src/convex/bugReports.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
|
-
import { mutation, query, internalMutation } from "./_generated/server";
|
|
2
|
+
import { mutation, query, internalMutation, internalQuery } from "./_generated/server";
|
|
3
3
|
import { internal } from "./_generated/api";
|
|
4
4
|
import {
|
|
5
5
|
bugSeverityValidator,
|
|
@@ -41,6 +41,7 @@ const bugReportReturnValidator = v.object({
|
|
|
41
41
|
aiAnalyzedAt: v.optional(v.number()),
|
|
42
42
|
aiThreadId: v.optional(v.string()),
|
|
43
43
|
notificationsSentAt: v.optional(v.number()),
|
|
44
|
+
ticketNumber: v.optional(v.string()),
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
/**
|
|
@@ -66,11 +67,39 @@ export const create = mutation({
|
|
|
66
67
|
viewportWidth: v.number(),
|
|
67
68
|
viewportHeight: v.number(),
|
|
68
69
|
networkState: v.string(),
|
|
70
|
+
// When true, skip auto-scheduling of AI analysis/email notifications
|
|
71
|
+
// Use this when the parent app will handle processing with its own API keys
|
|
72
|
+
skipAutoProcess: v.optional(v.boolean()),
|
|
73
|
+
// Optional API keys - pass from parent app since components don't inherit env vars
|
|
74
|
+
openRouterApiKey: v.optional(v.string()),
|
|
75
|
+
resendApiKey: v.optional(v.string()),
|
|
76
|
+
resendFromEmail: v.optional(v.string()),
|
|
69
77
|
},
|
|
70
78
|
returns: v.id("bugReports"),
|
|
71
79
|
handler: async (ctx, args) => {
|
|
72
80
|
const now = Date.now();
|
|
73
81
|
|
|
82
|
+
// Generate ticket number
|
|
83
|
+
const year = new Date().getFullYear();
|
|
84
|
+
const counter = await ctx.db
|
|
85
|
+
.query("ticketCounters")
|
|
86
|
+
.withIndex("by_type_year", (q) => q.eq("type", "bug").eq("year", year))
|
|
87
|
+
.unique();
|
|
88
|
+
|
|
89
|
+
let nextNumber: number;
|
|
90
|
+
if (counter) {
|
|
91
|
+
nextNumber = counter.nextNumber;
|
|
92
|
+
await ctx.db.patch(counter._id, { nextNumber: nextNumber + 1 });
|
|
93
|
+
} else {
|
|
94
|
+
nextNumber = 1;
|
|
95
|
+
await ctx.db.insert("ticketCounters", {
|
|
96
|
+
type: "bug",
|
|
97
|
+
year,
|
|
98
|
+
nextNumber: 2,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const ticketNumber = `BUG-${year}-${nextNumber.toString().padStart(4, "0")}`;
|
|
102
|
+
|
|
74
103
|
const reportId = await ctx.db.insert("bugReports", {
|
|
75
104
|
title: args.title,
|
|
76
105
|
description: args.description,
|
|
@@ -88,19 +117,27 @@ export const create = mutation({
|
|
|
88
117
|
viewportWidth: args.viewportWidth,
|
|
89
118
|
viewportHeight: args.viewportHeight,
|
|
90
119
|
networkState: args.networkState,
|
|
120
|
+
ticketNumber,
|
|
91
121
|
createdAt: now,
|
|
92
122
|
updatedAt: now,
|
|
93
123
|
});
|
|
94
124
|
|
|
95
125
|
// Schedule AI analysis and email notifications (runs immediately)
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
126
|
+
// Skip if parent app will handle processing with its own API keys
|
|
127
|
+
if (!args.skipAutoProcess) {
|
|
128
|
+
// This uses a try-catch to gracefully handle if agents aren't configured
|
|
129
|
+
try {
|
|
130
|
+
await ctx.scheduler.runAfter(0, internal.agents.bugReportAgent.processBugReport, {
|
|
131
|
+
bugReportId: reportId,
|
|
132
|
+
// Forward API keys from parent app (components don't inherit env vars)
|
|
133
|
+
openRouterApiKey: args.openRouterApiKey,
|
|
134
|
+
resendApiKey: args.resendApiKey,
|
|
135
|
+
resendFromEmail: args.resendFromEmail,
|
|
136
|
+
});
|
|
137
|
+
} catch {
|
|
138
|
+
// AI agent not available, continue without analysis
|
|
139
|
+
console.log("AI agent not configured, skipping analysis");
|
|
140
|
+
}
|
|
104
141
|
}
|
|
105
142
|
|
|
106
143
|
return reportId;
|
|
@@ -332,6 +369,108 @@ export const updateWithAnalysis = internalMutation({
|
|
|
332
369
|
},
|
|
333
370
|
});
|
|
334
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Internal query to list bug reports (for HTTP API).
|
|
374
|
+
*/
|
|
375
|
+
export const listInternal = internalQuery({
|
|
376
|
+
args: {
|
|
377
|
+
status: v.optional(bugStatusValidator),
|
|
378
|
+
severity: v.optional(bugSeverityValidator),
|
|
379
|
+
includeArchived: v.optional(v.boolean()),
|
|
380
|
+
limit: v.optional(v.number()),
|
|
381
|
+
},
|
|
382
|
+
returns: v.array(bugReportReturnValidator),
|
|
383
|
+
handler: async (ctx, args) => {
|
|
384
|
+
const limit = args.limit ?? 50;
|
|
385
|
+
const includeArchived = args.includeArchived ?? false;
|
|
386
|
+
|
|
387
|
+
let reports;
|
|
388
|
+
if (args.status) {
|
|
389
|
+
const status = args.status;
|
|
390
|
+
reports = await ctx.db
|
|
391
|
+
.query("bugReports")
|
|
392
|
+
.withIndex("by_status", (q) => q.eq("status", status))
|
|
393
|
+
.order("desc")
|
|
394
|
+
.take(limit * 2);
|
|
395
|
+
} else if (args.severity) {
|
|
396
|
+
const severity = args.severity;
|
|
397
|
+
reports = await ctx.db
|
|
398
|
+
.query("bugReports")
|
|
399
|
+
.withIndex("by_severity", (q) => q.eq("severity", severity))
|
|
400
|
+
.order("desc")
|
|
401
|
+
.take(limit * 2);
|
|
402
|
+
} else {
|
|
403
|
+
reports = await ctx.db
|
|
404
|
+
.query("bugReports")
|
|
405
|
+
.withIndex("by_created")
|
|
406
|
+
.order("desc")
|
|
407
|
+
.take(limit * 2);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (!includeArchived) {
|
|
411
|
+
reports = reports.filter((r) => !r.isArchived);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return reports.slice(0, limit);
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Internal mutation to update bug report status by ticket number (for HTTP API).
|
|
420
|
+
*/
|
|
421
|
+
export const updateStatusByTicketNumber = internalMutation({
|
|
422
|
+
args: {
|
|
423
|
+
ticketNumber: v.string(),
|
|
424
|
+
status: bugStatusValidator,
|
|
425
|
+
},
|
|
426
|
+
returns: v.union(v.object({ success: v.boolean(), ticketNumber: v.string() }), v.null()),
|
|
427
|
+
handler: async (ctx, args) => {
|
|
428
|
+
const report = await ctx.db
|
|
429
|
+
.query("bugReports")
|
|
430
|
+
.withIndex("by_ticket_number", (q) => q.eq("ticketNumber", args.ticketNumber))
|
|
431
|
+
.unique();
|
|
432
|
+
|
|
433
|
+
if (!report) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
await ctx.db.patch(report._id, {
|
|
438
|
+
status: args.status,
|
|
439
|
+
updatedAt: Date.now(),
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
return { success: true, ticketNumber: args.ticketNumber };
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Internal mutation to archive/unarchive bug report by ticket number (for HTTP API).
|
|
448
|
+
*/
|
|
449
|
+
export const setArchivedByTicketNumber = internalMutation({
|
|
450
|
+
args: {
|
|
451
|
+
ticketNumber: v.string(),
|
|
452
|
+
isArchived: v.boolean(),
|
|
453
|
+
},
|
|
454
|
+
returns: v.union(v.object({ success: v.boolean(), ticketNumber: v.string(), isArchived: v.boolean() }), v.null()),
|
|
455
|
+
handler: async (ctx, args) => {
|
|
456
|
+
const report = await ctx.db
|
|
457
|
+
.query("bugReports")
|
|
458
|
+
.withIndex("by_ticket_number", (q) => q.eq("ticketNumber", args.ticketNumber))
|
|
459
|
+
.unique();
|
|
460
|
+
|
|
461
|
+
if (!report) {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
await ctx.db.patch(report._id, {
|
|
466
|
+
isArchived: args.isArchived,
|
|
467
|
+
updatedAt: Date.now(),
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
return { success: true, ticketNumber: args.ticketNumber, isArchived: args.isArchived };
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
|
|
335
474
|
/**
|
|
336
475
|
* Internal mutation to mark notifications as sent
|
|
337
476
|
*/
|
|
@@ -347,6 +347,9 @@ export const sendBugReportNotifications = internalAction({
|
|
|
347
347
|
}),
|
|
348
348
|
v.null()
|
|
349
349
|
),
|
|
350
|
+
// Optional API keys - passed from parent app since components don't inherit env vars
|
|
351
|
+
resendApiKey: v.optional(v.string()),
|
|
352
|
+
resendFromEmail: v.optional(v.string()),
|
|
350
353
|
},
|
|
351
354
|
returns: v.object({
|
|
352
355
|
success: v.boolean(),
|
|
@@ -363,8 +366,8 @@ export const sendBugReportNotifications = internalAction({
|
|
|
363
366
|
teamEmailsSent: number;
|
|
364
367
|
error?: string;
|
|
365
368
|
}> => {
|
|
366
|
-
// Check for Resend API key
|
|
367
|
-
const resendApiKey = process.env.RESEND_API_KEY;
|
|
369
|
+
// Check for Resend API key - prefer args (from parent app), fallback to env
|
|
370
|
+
const resendApiKey = args.resendApiKey || process.env.RESEND_API_KEY;
|
|
368
371
|
if (!resendApiKey) {
|
|
369
372
|
console.warn("RESEND_API_KEY not configured. Skipping email notifications.");
|
|
370
373
|
return {
|
|
@@ -376,7 +379,7 @@ export const sendBugReportNotifications = internalAction({
|
|
|
376
379
|
}
|
|
377
380
|
|
|
378
381
|
const resend = new Resend(resendApiKey);
|
|
379
|
-
const fromEmail = process.env.RESEND_FROM_EMAIL || "bugs@resend.dev";
|
|
382
|
+
const fromEmail = args.resendFromEmail || process.env.RESEND_FROM_EMAIL || "bugs@resend.dev";
|
|
380
383
|
|
|
381
384
|
// Get bug report data
|
|
382
385
|
const report = await ctx.runQuery(
|
|
@@ -328,6 +328,9 @@ export const sendFeedbackNotifications = internalAction({
|
|
|
328
328
|
}),
|
|
329
329
|
v.null()
|
|
330
330
|
),
|
|
331
|
+
// Optional API keys - passed from parent app since components don't inherit env vars
|
|
332
|
+
resendApiKey: v.optional(v.string()),
|
|
333
|
+
resendFromEmail: v.optional(v.string()),
|
|
331
334
|
},
|
|
332
335
|
returns: v.object({
|
|
333
336
|
success: v.boolean(),
|
|
@@ -344,8 +347,8 @@ export const sendFeedbackNotifications = internalAction({
|
|
|
344
347
|
teamEmailsSent: number;
|
|
345
348
|
error?: string;
|
|
346
349
|
}> => {
|
|
347
|
-
// Check for Resend API key
|
|
348
|
-
const resendApiKey = process.env.RESEND_API_KEY;
|
|
350
|
+
// Check for Resend API key - prefer args (from parent app), fallback to env
|
|
351
|
+
const resendApiKey = args.resendApiKey || process.env.RESEND_API_KEY;
|
|
349
352
|
if (!resendApiKey) {
|
|
350
353
|
console.warn("RESEND_API_KEY not configured. Skipping email notifications.");
|
|
351
354
|
return {
|
|
@@ -357,7 +360,7 @@ export const sendFeedbackNotifications = internalAction({
|
|
|
357
360
|
}
|
|
358
361
|
|
|
359
362
|
const resend = new Resend(resendApiKey);
|
|
360
|
-
const fromEmail = process.env.RESEND_FROM_EMAIL || "feedback@resend.dev";
|
|
363
|
+
const fromEmail = args.resendFromEmail || process.env.RESEND_FROM_EMAIL || "feedback@resend.dev";
|
|
361
364
|
|
|
362
365
|
// Get feedback data
|
|
363
366
|
const feedback = await ctx.runQuery(internal.emails.feedbackEmails.getFeedbackForEmail, {
|
package/src/convex/feedback.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
|
-
import { mutation, query, internalMutation } from "./_generated/server";
|
|
2
|
+
import { mutation, query, internalMutation, internalQuery } from "./_generated/server";
|
|
3
3
|
import { internal } from "./_generated/api";
|
|
4
4
|
import {
|
|
5
5
|
feedbackTypeValidator,
|
|
@@ -42,6 +42,7 @@ const feedbackReturnValidator = v.object({
|
|
|
42
42
|
aiAnalyzedAt: v.optional(v.number()),
|
|
43
43
|
aiThreadId: v.optional(v.string()),
|
|
44
44
|
notificationsSentAt: v.optional(v.number()),
|
|
45
|
+
ticketNumber: v.optional(v.string()),
|
|
45
46
|
});
|
|
46
47
|
|
|
47
48
|
/**
|
|
@@ -68,11 +69,39 @@ export const create = mutation({
|
|
|
68
69
|
viewportWidth: v.number(),
|
|
69
70
|
viewportHeight: v.number(),
|
|
70
71
|
networkState: v.string(),
|
|
72
|
+
// When true, skip auto-scheduling of AI analysis/email notifications
|
|
73
|
+
// Use this when the parent app will handle processing with its own API keys
|
|
74
|
+
skipAutoProcess: v.optional(v.boolean()),
|
|
75
|
+
// Optional API keys - pass from parent app since components don't inherit env vars
|
|
76
|
+
openRouterApiKey: v.optional(v.string()),
|
|
77
|
+
resendApiKey: v.optional(v.string()),
|
|
78
|
+
resendFromEmail: v.optional(v.string()),
|
|
71
79
|
},
|
|
72
80
|
returns: v.id("feedback"),
|
|
73
81
|
handler: async (ctx, args) => {
|
|
74
82
|
const now = Date.now();
|
|
75
83
|
|
|
84
|
+
// Generate ticket number
|
|
85
|
+
const year = new Date().getFullYear();
|
|
86
|
+
const counter = await ctx.db
|
|
87
|
+
.query("ticketCounters")
|
|
88
|
+
.withIndex("by_type_year", (q) => q.eq("type", "feedback").eq("year", year))
|
|
89
|
+
.unique();
|
|
90
|
+
|
|
91
|
+
let nextNumber: number;
|
|
92
|
+
if (counter) {
|
|
93
|
+
nextNumber = counter.nextNumber;
|
|
94
|
+
await ctx.db.patch(counter._id, { nextNumber: nextNumber + 1 });
|
|
95
|
+
} else {
|
|
96
|
+
nextNumber = 1;
|
|
97
|
+
await ctx.db.insert("ticketCounters", {
|
|
98
|
+
type: "feedback",
|
|
99
|
+
year,
|
|
100
|
+
nextNumber: 2,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const ticketNumber = `FB-${year}-${nextNumber.toString().padStart(4, "0")}`;
|
|
104
|
+
|
|
76
105
|
const feedbackId = await ctx.db.insert("feedback", {
|
|
77
106
|
type: args.type,
|
|
78
107
|
title: args.title,
|
|
@@ -91,18 +120,26 @@ export const create = mutation({
|
|
|
91
120
|
viewportWidth: args.viewportWidth,
|
|
92
121
|
viewportHeight: args.viewportHeight,
|
|
93
122
|
networkState: args.networkState,
|
|
123
|
+
ticketNumber,
|
|
94
124
|
createdAt: now,
|
|
95
125
|
updatedAt: now,
|
|
96
126
|
});
|
|
97
127
|
|
|
98
128
|
// Schedule AI analysis and email notifications (runs immediately)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
129
|
+
// Skip if parent app will handle processing with its own API keys
|
|
130
|
+
if (!args.skipAutoProcess) {
|
|
131
|
+
try {
|
|
132
|
+
await ctx.scheduler.runAfter(0, internal.agents.feedbackAgent.processFeedback, {
|
|
133
|
+
feedbackId,
|
|
134
|
+
// Forward API keys from parent app (components don't inherit env vars)
|
|
135
|
+
openRouterApiKey: args.openRouterApiKey,
|
|
136
|
+
resendApiKey: args.resendApiKey,
|
|
137
|
+
resendFromEmail: args.resendFromEmail,
|
|
138
|
+
});
|
|
139
|
+
} catch {
|
|
140
|
+
// AI agent not available, continue without analysis
|
|
141
|
+
console.log("AI agent not configured, skipping analysis");
|
|
142
|
+
}
|
|
106
143
|
}
|
|
107
144
|
|
|
108
145
|
return feedbackId;
|
|
@@ -354,3 +391,113 @@ export const markNotificationsSent = internalMutation({
|
|
|
354
391
|
return null;
|
|
355
392
|
},
|
|
356
393
|
});
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Internal query to list feedback (for HTTP API).
|
|
397
|
+
*/
|
|
398
|
+
export const listInternal = internalQuery({
|
|
399
|
+
args: {
|
|
400
|
+
status: v.optional(feedbackStatusValidator),
|
|
401
|
+
type: v.optional(feedbackTypeValidator),
|
|
402
|
+
priority: v.optional(feedbackPriorityValidator),
|
|
403
|
+
includeArchived: v.optional(v.boolean()),
|
|
404
|
+
limit: v.optional(v.number()),
|
|
405
|
+
},
|
|
406
|
+
returns: v.array(feedbackReturnValidator),
|
|
407
|
+
handler: async (ctx, args) => {
|
|
408
|
+
const limit = args.limit ?? 50;
|
|
409
|
+
const includeArchived = args.includeArchived ?? false;
|
|
410
|
+
|
|
411
|
+
let results;
|
|
412
|
+
if (args.status) {
|
|
413
|
+
const status = args.status;
|
|
414
|
+
results = await ctx.db
|
|
415
|
+
.query("feedback")
|
|
416
|
+
.withIndex("by_status", (q) => q.eq("status", status))
|
|
417
|
+
.order("desc")
|
|
418
|
+
.take(limit * 2);
|
|
419
|
+
} else if (args.type) {
|
|
420
|
+
const type = args.type;
|
|
421
|
+
results = await ctx.db
|
|
422
|
+
.query("feedback")
|
|
423
|
+
.withIndex("by_type", (q) => q.eq("type", type))
|
|
424
|
+
.order("desc")
|
|
425
|
+
.take(limit * 2);
|
|
426
|
+
} else if (args.priority) {
|
|
427
|
+
const priority = args.priority;
|
|
428
|
+
results = await ctx.db
|
|
429
|
+
.query("feedback")
|
|
430
|
+
.withIndex("by_priority", (q) => q.eq("priority", priority))
|
|
431
|
+
.order("desc")
|
|
432
|
+
.take(limit * 2);
|
|
433
|
+
} else {
|
|
434
|
+
results = await ctx.db
|
|
435
|
+
.query("feedback")
|
|
436
|
+
.withIndex("by_created")
|
|
437
|
+
.order("desc")
|
|
438
|
+
.take(limit * 2);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!includeArchived) {
|
|
442
|
+
results = results.filter((r) => !r.isArchived);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return results.slice(0, limit);
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Internal mutation to archive/unarchive feedback by ticket number (for HTTP API).
|
|
451
|
+
*/
|
|
452
|
+
export const setArchivedByTicketNumber = internalMutation({
|
|
453
|
+
args: {
|
|
454
|
+
ticketNumber: v.string(),
|
|
455
|
+
isArchived: v.boolean(),
|
|
456
|
+
},
|
|
457
|
+
returns: v.union(v.object({ success: v.boolean(), ticketNumber: v.string(), isArchived: v.boolean() }), v.null()),
|
|
458
|
+
handler: async (ctx, args) => {
|
|
459
|
+
const feedback = await ctx.db
|
|
460
|
+
.query("feedback")
|
|
461
|
+
.withIndex("by_ticket_number", (q) => q.eq("ticketNumber", args.ticketNumber))
|
|
462
|
+
.unique();
|
|
463
|
+
|
|
464
|
+
if (!feedback) {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
await ctx.db.patch(feedback._id, {
|
|
469
|
+
isArchived: args.isArchived,
|
|
470
|
+
updatedAt: Date.now(),
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
return { success: true, ticketNumber: args.ticketNumber, isArchived: args.isArchived };
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Internal mutation to update feedback status by ticket number (for HTTP API).
|
|
479
|
+
*/
|
|
480
|
+
export const updateStatusByTicketNumber = internalMutation({
|
|
481
|
+
args: {
|
|
482
|
+
ticketNumber: v.string(),
|
|
483
|
+
status: feedbackStatusValidator,
|
|
484
|
+
},
|
|
485
|
+
returns: v.union(v.object({ success: v.boolean(), ticketNumber: v.string() }), v.null()),
|
|
486
|
+
handler: async (ctx, args) => {
|
|
487
|
+
const feedback = await ctx.db
|
|
488
|
+
.query("feedback")
|
|
489
|
+
.withIndex("by_ticket_number", (q) => q.eq("ticketNumber", args.ticketNumber))
|
|
490
|
+
.unique();
|
|
491
|
+
|
|
492
|
+
if (!feedback) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
await ctx.db.patch(feedback._id, {
|
|
497
|
+
status: args.status,
|
|
498
|
+
updatedAt: Date.now(),
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
return { success: true, ticketNumber: args.ticketNumber };
|
|
502
|
+
},
|
|
503
|
+
});
|