@fatagnus/convex-feedback 0.2.7 → 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/package.json +12 -5
- package/src/convex/_generated/api.ts +1 -0
- package/src/convex/agents/feedbackInterviewAgent.ts +6 -12
- package/src/convex/apiKeys.test.ts +79 -0
- package/src/convex/apiKeys.ts +223 -0
- package/src/convex/bugReports.ts +126 -1
- package/src/convex/feedback.ts +134 -1
- 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 +33 -1
- package/src/types.ts +38 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import { httpRouter, HttpRouter } from "convex/server";
|
|
2
|
+
import { httpAction } from "./_generated/server";
|
|
3
|
+
import { internal } from "./_generated/api";
|
|
4
|
+
import { generateBugPrompt, generateFeedbackPrompt, PromptTemplate } from "./prompts";
|
|
5
|
+
import type { RegisterFeedbackRoutesOptions } from "../types";
|
|
6
|
+
|
|
7
|
+
export type { RegisterFeedbackRoutesOptions };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate API key from Authorization header
|
|
11
|
+
*/
|
|
12
|
+
async function validateApiKey(
|
|
13
|
+
ctx: { runQuery: (fn: unknown, args: unknown) => Promise<unknown>; runMutation?: (fn: unknown, args: unknown) => Promise<unknown> },
|
|
14
|
+
authHeader: string | null
|
|
15
|
+
): Promise<boolean> {
|
|
16
|
+
if (!authHeader) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Expect: "Bearer fb_xxxxx"
|
|
21
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
22
|
+
if (!match) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const key = match[1];
|
|
27
|
+
return (await ctx.runQuery(internal.apiKeys.validateKey, { key })) as boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse template from query string
|
|
32
|
+
*/
|
|
33
|
+
function parseTemplate(url: URL): PromptTemplate {
|
|
34
|
+
const template = url.searchParams.get("template");
|
|
35
|
+
if (template === "fix" || template === "implement" || template === "analyze" || template === "codebuff") {
|
|
36
|
+
return template;
|
|
37
|
+
}
|
|
38
|
+
// Return undefined to let the caller decide the default
|
|
39
|
+
return "fix";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Register all feedback HTTP routes on the provided router.
|
|
44
|
+
*
|
|
45
|
+
* This allows consumer apps to integrate the feedback API into their own HTTP router.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* // In your app's convex/http.ts:
|
|
50
|
+
* import { httpRouter } from "convex/server";
|
|
51
|
+
* import { registerFeedbackRoutes } from "@convex-dev/feedback/convex";
|
|
52
|
+
*
|
|
53
|
+
* const http = httpRouter();
|
|
54
|
+
*
|
|
55
|
+
* // Register feedback routes with optional prefix
|
|
56
|
+
* registerFeedbackRoutes(http, { pathPrefix: "/feedback" });
|
|
57
|
+
*
|
|
58
|
+
* // Your other routes...
|
|
59
|
+
* http.route({ path: "/api/health", method: "GET", handler: ... });
|
|
60
|
+
*
|
|
61
|
+
* export default http;
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* Registered routes:
|
|
65
|
+
* - GET {prefix}/api/prompt/{ticketNumber} - Fetch AI prompt for a ticket
|
|
66
|
+
* - GET {prefix}/api/items - List bug reports and feedback
|
|
67
|
+
* - GET {prefix}/api/items/{ticketNumber} - Get a single item
|
|
68
|
+
* - PATCH {prefix}/api/items/{ticketNumber}/status - Update item status
|
|
69
|
+
* - PATCH {prefix}/api/items/{ticketNumber}/archive - Archive/unarchive item
|
|
70
|
+
*
|
|
71
|
+
* @param router - The HttpRouter to register routes on
|
|
72
|
+
* @param options - Optional configuration
|
|
73
|
+
*/
|
|
74
|
+
export function registerFeedbackRoutes(
|
|
75
|
+
router: HttpRouter,
|
|
76
|
+
options: RegisterFeedbackRoutesOptions = {}
|
|
77
|
+
): void {
|
|
78
|
+
const prefix = options.pathPrefix ?? "";
|
|
79
|
+
|
|
80
|
+
// GET /api/prompt/{ticketNumber}
|
|
81
|
+
router.route({
|
|
82
|
+
path: `${prefix}/api/prompt/{ticketNumber}`,
|
|
83
|
+
method: "GET",
|
|
84
|
+
handler: httpAction(async (ctx, request) => {
|
|
85
|
+
// Validate API key
|
|
86
|
+
const authHeader = request.headers.get("Authorization");
|
|
87
|
+
const isValid = await validateApiKey(ctx, authHeader);
|
|
88
|
+
|
|
89
|
+
if (!isValid) {
|
|
90
|
+
return new Response(
|
|
91
|
+
JSON.stringify({ error: "Unauthorized", message: "Invalid or missing API key" }),
|
|
92
|
+
{
|
|
93
|
+
status: 401,
|
|
94
|
+
headers: { "Content-Type": "application/json" },
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Extract ticket number from URL
|
|
100
|
+
const url = new URL(request.url);
|
|
101
|
+
const pathParts = url.pathname.split("/");
|
|
102
|
+
const ticketNumber = pathParts[pathParts.length - 1]?.toUpperCase();
|
|
103
|
+
|
|
104
|
+
if (!ticketNumber) {
|
|
105
|
+
return new Response(
|
|
106
|
+
JSON.stringify({ error: "Bad request", message: "Missing ticket number" }),
|
|
107
|
+
{
|
|
108
|
+
status: 400,
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const template = parseTemplate(url);
|
|
115
|
+
const isBug = ticketNumber.startsWith("BUG-");
|
|
116
|
+
const isFeedback = ticketNumber.startsWith("FB-");
|
|
117
|
+
|
|
118
|
+
if (!isBug && !isFeedback) {
|
|
119
|
+
return new Response(
|
|
120
|
+
JSON.stringify({ error: "Bad request", message: "Invalid ticket number format" }),
|
|
121
|
+
{
|
|
122
|
+
status: 400,
|
|
123
|
+
headers: { "Content-Type": "application/json" },
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (isBug) {
|
|
129
|
+
const bug = await ctx.runQuery(internal.prompts.getBugByTicketNumber, { ticketNumber });
|
|
130
|
+
|
|
131
|
+
if (!bug) {
|
|
132
|
+
return new Response(
|
|
133
|
+
JSON.stringify({ error: "Not found", message: `Bug report ${ticketNumber} not found` }),
|
|
134
|
+
{
|
|
135
|
+
status: 404,
|
|
136
|
+
headers: { "Content-Type": "application/json" },
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const prompt = generateBugPrompt(
|
|
142
|
+
{
|
|
143
|
+
ticketNumber: bug.ticketNumber,
|
|
144
|
+
title: bug.title,
|
|
145
|
+
description: bug.description,
|
|
146
|
+
severity: bug.severity,
|
|
147
|
+
url: bug.url,
|
|
148
|
+
route: bug.route,
|
|
149
|
+
browserInfo: bug.browserInfo,
|
|
150
|
+
consoleErrors: bug.consoleErrors,
|
|
151
|
+
viewportWidth: bug.viewportWidth,
|
|
152
|
+
viewportHeight: bug.viewportHeight,
|
|
153
|
+
aiSummary: bug.aiSummary,
|
|
154
|
+
aiRootCauseAnalysis: bug.aiRootCauseAnalysis,
|
|
155
|
+
aiReproductionSteps: bug.aiReproductionSteps,
|
|
156
|
+
aiSuggestedFix: bug.aiSuggestedFix,
|
|
157
|
+
aiEstimatedEffort: bug.aiEstimatedEffort,
|
|
158
|
+
createdAt: bug.createdAt,
|
|
159
|
+
},
|
|
160
|
+
template
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return new Response(
|
|
164
|
+
JSON.stringify({
|
|
165
|
+
ticketNumber: bug.ticketNumber,
|
|
166
|
+
type: "bug",
|
|
167
|
+
template,
|
|
168
|
+
prompt,
|
|
169
|
+
}),
|
|
170
|
+
{
|
|
171
|
+
status: 200,
|
|
172
|
+
headers: { "Content-Type": "application/json" },
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Feedback
|
|
178
|
+
const feedback = await ctx.runQuery(internal.prompts.getFeedbackByTicketNumber, { ticketNumber });
|
|
179
|
+
|
|
180
|
+
if (!feedback) {
|
|
181
|
+
return new Response(
|
|
182
|
+
JSON.stringify({ error: "Not found", message: `Feedback ${ticketNumber} not found` }),
|
|
183
|
+
{
|
|
184
|
+
status: 404,
|
|
185
|
+
headers: { "Content-Type": "application/json" },
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// For feedback, default to "implement" instead of "fix"
|
|
191
|
+
const feedbackTemplate = template === "fix" && !url.searchParams.has("template") ? "implement" : template;
|
|
192
|
+
|
|
193
|
+
const prompt = generateFeedbackPrompt(
|
|
194
|
+
{
|
|
195
|
+
ticketNumber: feedback.ticketNumber,
|
|
196
|
+
type: feedback.type,
|
|
197
|
+
title: feedback.title,
|
|
198
|
+
description: feedback.description,
|
|
199
|
+
priority: feedback.priority,
|
|
200
|
+
url: feedback.url,
|
|
201
|
+
route: feedback.route,
|
|
202
|
+
aiSummary: feedback.aiSummary,
|
|
203
|
+
aiImpactAnalysis: feedback.aiImpactAnalysis,
|
|
204
|
+
aiActionItems: feedback.aiActionItems,
|
|
205
|
+
aiEstimatedEffort: feedback.aiEstimatedEffort,
|
|
206
|
+
createdAt: feedback.createdAt,
|
|
207
|
+
},
|
|
208
|
+
feedbackTemplate
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
return new Response(
|
|
212
|
+
JSON.stringify({
|
|
213
|
+
ticketNumber: feedback.ticketNumber,
|
|
214
|
+
type: "feedback",
|
|
215
|
+
template: feedbackTemplate,
|
|
216
|
+
prompt,
|
|
217
|
+
}),
|
|
218
|
+
{
|
|
219
|
+
status: 200,
|
|
220
|
+
headers: { "Content-Type": "application/json" },
|
|
221
|
+
}
|
|
222
|
+
);
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// GET /api/items/{ticketNumber}
|
|
227
|
+
/**
|
|
228
|
+
* GET /api/items/{ticketNumber}
|
|
229
|
+
*
|
|
230
|
+
* Fetch a single bug report or feedback item by ticket number.
|
|
231
|
+
*
|
|
232
|
+
* Path params:
|
|
233
|
+
* - ticketNumber: e.g., "BUG-2025-0001" or "FB-2025-0001"
|
|
234
|
+
*
|
|
235
|
+
* Headers:
|
|
236
|
+
* - Authorization: Bearer <api-key>
|
|
237
|
+
*
|
|
238
|
+
* Returns:
|
|
239
|
+
* - 200: Full item data
|
|
240
|
+
* - 400: { error: "Bad request" }
|
|
241
|
+
* - 401: { error: "Unauthorized" }
|
|
242
|
+
* - 404: { error: "Not found" }
|
|
243
|
+
*/
|
|
244
|
+
router.route({
|
|
245
|
+
path: `${prefix}/api/items/{ticketNumber}`,
|
|
246
|
+
method: "GET",
|
|
247
|
+
handler: httpAction(async (ctx, request) => {
|
|
248
|
+
const authHeader = request.headers.get("Authorization");
|
|
249
|
+
const isValid = await validateApiKey(ctx, authHeader);
|
|
250
|
+
|
|
251
|
+
if (!isValid) {
|
|
252
|
+
return new Response(
|
|
253
|
+
JSON.stringify({ error: "Unauthorized", message: "Invalid or missing API key" }),
|
|
254
|
+
{ status: 401, headers: { "Content-Type": "application/json" } }
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Extract ticket number from URL
|
|
259
|
+
const url = new URL(request.url);
|
|
260
|
+
const pathParts = url.pathname.split("/");
|
|
261
|
+
const ticketNumber = pathParts[pathParts.length - 1]?.toUpperCase();
|
|
262
|
+
|
|
263
|
+
if (!ticketNumber) {
|
|
264
|
+
return new Response(
|
|
265
|
+
JSON.stringify({ error: "Bad request", message: "Missing ticket number" }),
|
|
266
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const isBug = ticketNumber.startsWith("BUG-");
|
|
271
|
+
const isFeedback = ticketNumber.startsWith("FB-");
|
|
272
|
+
|
|
273
|
+
if (!isBug && !isFeedback) {
|
|
274
|
+
return new Response(
|
|
275
|
+
JSON.stringify({ error: "Bad request", message: "Invalid ticket number format. Expected BUG-YYYY-NNNN or FB-YYYY-NNNN" }),
|
|
276
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (isBug) {
|
|
281
|
+
const bug = await ctx.runQuery(internal.prompts.getBugByTicketNumber, { ticketNumber });
|
|
282
|
+
|
|
283
|
+
if (!bug) {
|
|
284
|
+
return new Response(
|
|
285
|
+
JSON.stringify({ error: "Not found", message: `Bug report ${ticketNumber} not found` }),
|
|
286
|
+
{ status: 404, headers: { "Content-Type": "application/json" } }
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return new Response(
|
|
291
|
+
JSON.stringify({ type: "bug", ...bug }),
|
|
292
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Feedback
|
|
297
|
+
const feedback = await ctx.runQuery(internal.prompts.getFeedbackByTicketNumber, { ticketNumber });
|
|
298
|
+
|
|
299
|
+
if (!feedback) {
|
|
300
|
+
return new Response(
|
|
301
|
+
JSON.stringify({ error: "Not found", message: `Feedback ${ticketNumber} not found` }),
|
|
302
|
+
{ status: 404, headers: { "Content-Type": "application/json" } }
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return new Response(
|
|
307
|
+
JSON.stringify({ type: "feedback", ...feedback }),
|
|
308
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
309
|
+
);
|
|
310
|
+
}),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// GET /api/items
|
|
314
|
+
/**
|
|
315
|
+
* GET /api/items
|
|
316
|
+
*
|
|
317
|
+
* List bug reports and/or feedback items with optional filters.
|
|
318
|
+
*
|
|
319
|
+
* Query params:
|
|
320
|
+
* - itemType: "bug" | "feedback" | "all" (default: "all")
|
|
321
|
+
* - status: filter by status (bug: open|in-progress|resolved|closed, feedback: open|under_review|planned|in_progress|completed|declined)
|
|
322
|
+
* - severity: filter bugs by severity (low|medium|high|critical)
|
|
323
|
+
* - priority: filter feedback by priority (nice_to_have|important|critical)
|
|
324
|
+
* - type: filter feedback by type (feature_request|change_request|general)
|
|
325
|
+
* - limit: max items to return (default: 50)
|
|
326
|
+
* - includeArchived: include archived items (default: false)
|
|
327
|
+
*
|
|
328
|
+
* Headers:
|
|
329
|
+
* - Authorization: Bearer <api-key>
|
|
330
|
+
*
|
|
331
|
+
* Returns:
|
|
332
|
+
* - 200: { bugs: [...], feedback: [...] }
|
|
333
|
+
* - 401: { error: "Unauthorized" }
|
|
334
|
+
*/
|
|
335
|
+
router.route({
|
|
336
|
+
path: `${prefix}/api/items`,
|
|
337
|
+
method: "GET",
|
|
338
|
+
handler: httpAction(async (ctx, request) => {
|
|
339
|
+
const authHeader = request.headers.get("Authorization");
|
|
340
|
+
const isValid = await validateApiKey(ctx, authHeader);
|
|
341
|
+
|
|
342
|
+
if (!isValid) {
|
|
343
|
+
return new Response(
|
|
344
|
+
JSON.stringify({ error: "Unauthorized", message: "Invalid or missing API key" }),
|
|
345
|
+
{ status: 401, headers: { "Content-Type": "application/json" } }
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const url = new URL(request.url);
|
|
350
|
+
const itemType = url.searchParams.get("itemType") ?? "all";
|
|
351
|
+
const status = url.searchParams.get("status");
|
|
352
|
+
const severity = url.searchParams.get("severity");
|
|
353
|
+
const priority = url.searchParams.get("priority");
|
|
354
|
+
const feedbackType = url.searchParams.get("type");
|
|
355
|
+
const limitParam = url.searchParams.get("limit");
|
|
356
|
+
const limit = limitParam ? parseInt(limitParam, 10) : 50;
|
|
357
|
+
const includeArchived = url.searchParams.get("includeArchived") === "true";
|
|
358
|
+
|
|
359
|
+
const result: { bugs?: unknown[]; feedback?: unknown[] } = {};
|
|
360
|
+
|
|
361
|
+
// Fetch bugs if requested
|
|
362
|
+
if (itemType === "bug" || itemType === "all") {
|
|
363
|
+
const bugArgs: Record<string, unknown> = { limit, includeArchived };
|
|
364
|
+
if (status && ["open", "in-progress", "resolved", "closed"].includes(status)) {
|
|
365
|
+
bugArgs.status = status;
|
|
366
|
+
}
|
|
367
|
+
if (severity && ["low", "medium", "high", "critical"].includes(severity)) {
|
|
368
|
+
bugArgs.severity = severity;
|
|
369
|
+
}
|
|
370
|
+
result.bugs = (await ctx.runQuery(internal.bugReports.listInternal, bugArgs)) as unknown[];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Fetch feedback if requested
|
|
374
|
+
if (itemType === "feedback" || itemType === "all") {
|
|
375
|
+
const feedbackArgs: Record<string, unknown> = { limit, includeArchived };
|
|
376
|
+
if (status && ["open", "under_review", "planned", "in_progress", "completed", "declined"].includes(status)) {
|
|
377
|
+
feedbackArgs.status = status;
|
|
378
|
+
}
|
|
379
|
+
if (priority && ["nice_to_have", "important", "critical"].includes(priority)) {
|
|
380
|
+
feedbackArgs.priority = priority;
|
|
381
|
+
}
|
|
382
|
+
if (feedbackType && ["feature_request", "change_request", "general"].includes(feedbackType)) {
|
|
383
|
+
feedbackArgs.type = feedbackType;
|
|
384
|
+
}
|
|
385
|
+
result.feedback = (await ctx.runQuery(internal.feedback.listInternal, feedbackArgs)) as unknown[];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return new Response(JSON.stringify(result), {
|
|
389
|
+
status: 200,
|
|
390
|
+
headers: { "Content-Type": "application/json" },
|
|
391
|
+
});
|
|
392
|
+
}),
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// PATCH /api/items/{ticketNumber}/status
|
|
396
|
+
/**
|
|
397
|
+
* PATCH /api/items/{ticketNumber}/status
|
|
398
|
+
*
|
|
399
|
+
* Update the status of a bug report or feedback item.
|
|
400
|
+
*
|
|
401
|
+
* Path params:
|
|
402
|
+
* - ticketNumber: e.g., "BUG-2025-0001" or "FB-2025-0001"
|
|
403
|
+
*
|
|
404
|
+
* Body (JSON):
|
|
405
|
+
* - status: new status value
|
|
406
|
+
* - For bugs: "open" | "in-progress" | "resolved" | "closed"
|
|
407
|
+
* - For feedback: "open" | "under_review" | "planned" | "in_progress" | "completed" | "declined"
|
|
408
|
+
*
|
|
409
|
+
* Headers:
|
|
410
|
+
* - Authorization: Bearer <api-key>
|
|
411
|
+
* - Content-Type: application/json
|
|
412
|
+
*
|
|
413
|
+
* Returns:
|
|
414
|
+
* - 200: { success: true, ticketNumber: "..." }
|
|
415
|
+
* - 400: { error: "Bad request" }
|
|
416
|
+
* - 401: { error: "Unauthorized" }
|
|
417
|
+
* - 404: { error: "Not found" }
|
|
418
|
+
*/
|
|
419
|
+
router.route({
|
|
420
|
+
path: `${prefix}/api/items/{ticketNumber}/status`,
|
|
421
|
+
method: "PATCH",
|
|
422
|
+
handler: httpAction(async (ctx, request) => {
|
|
423
|
+
const authHeader = request.headers.get("Authorization");
|
|
424
|
+
const isValid = await validateApiKey(ctx, authHeader);
|
|
425
|
+
|
|
426
|
+
if (!isValid) {
|
|
427
|
+
return new Response(
|
|
428
|
+
JSON.stringify({ error: "Unauthorized", message: "Invalid or missing API key" }),
|
|
429
|
+
{ status: 401, headers: { "Content-Type": "application/json" } }
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Extract ticket number from URL
|
|
434
|
+
const url = new URL(request.url);
|
|
435
|
+
const pathParts = url.pathname.split("/");
|
|
436
|
+
// Path is /api/items/{ticketNumber}/status, so ticketNumber is second to last
|
|
437
|
+
const ticketNumber = pathParts[pathParts.length - 2]?.toUpperCase();
|
|
438
|
+
|
|
439
|
+
if (!ticketNumber) {
|
|
440
|
+
return new Response(
|
|
441
|
+
JSON.stringify({ error: "Bad request", message: "Missing ticket number" }),
|
|
442
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const isBug = ticketNumber.startsWith("BUG-");
|
|
447
|
+
const isFeedback = ticketNumber.startsWith("FB-");
|
|
448
|
+
|
|
449
|
+
if (!isBug && !isFeedback) {
|
|
450
|
+
return new Response(
|
|
451
|
+
JSON.stringify({ error: "Bad request", message: "Invalid ticket number format. Expected BUG-YYYY-NNNN or FB-YYYY-NNNN" }),
|
|
452
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Parse request body
|
|
457
|
+
let body: { status?: string };
|
|
458
|
+
try {
|
|
459
|
+
body = await request.json();
|
|
460
|
+
} catch {
|
|
461
|
+
return new Response(
|
|
462
|
+
JSON.stringify({ error: "Bad request", message: "Invalid JSON body" }),
|
|
463
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (!body.status) {
|
|
468
|
+
return new Response(
|
|
469
|
+
JSON.stringify({ error: "Bad request", message: "Missing status in request body" }),
|
|
470
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Validate status based on type
|
|
475
|
+
const bugStatuses = ["open", "in-progress", "resolved", "closed"];
|
|
476
|
+
const feedbackStatuses = ["open", "under_review", "planned", "in_progress", "completed", "declined"];
|
|
477
|
+
|
|
478
|
+
if (isBug && !bugStatuses.includes(body.status)) {
|
|
479
|
+
return new Response(
|
|
480
|
+
JSON.stringify({ error: "Bad request", message: `Invalid bug status. Expected one of: ${bugStatuses.join(", ")}` }),
|
|
481
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (isFeedback && !feedbackStatuses.includes(body.status)) {
|
|
486
|
+
return new Response(
|
|
487
|
+
JSON.stringify({ error: "Bad request", message: `Invalid feedback status. Expected one of: ${feedbackStatuses.join(", ")}` }),
|
|
488
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Update the status
|
|
493
|
+
let result;
|
|
494
|
+
if (isBug) {
|
|
495
|
+
result = await ctx.runMutation(internal.bugReports.updateStatusByTicketNumber, {
|
|
496
|
+
ticketNumber,
|
|
497
|
+
status: body.status as "open" | "in-progress" | "resolved" | "closed",
|
|
498
|
+
});
|
|
499
|
+
} else {
|
|
500
|
+
result = await ctx.runMutation(internal.feedback.updateStatusByTicketNumber, {
|
|
501
|
+
ticketNumber,
|
|
502
|
+
status: body.status as "open" | "under_review" | "planned" | "in_progress" | "completed" | "declined",
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (!result) {
|
|
507
|
+
return new Response(
|
|
508
|
+
JSON.stringify({ error: "Not found", message: `Item ${ticketNumber} not found` }),
|
|
509
|
+
{ status: 404, headers: { "Content-Type": "application/json" } }
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return new Response(JSON.stringify(result), {
|
|
514
|
+
status: 200,
|
|
515
|
+
headers: { "Content-Type": "application/json" },
|
|
516
|
+
});
|
|
517
|
+
}),
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// PATCH /api/items/{ticketNumber}/archive
|
|
521
|
+
/**
|
|
522
|
+
* PATCH /api/items/{ticketNumber}/archive
|
|
523
|
+
*
|
|
524
|
+
* Archive or unarchive a bug report or feedback item.
|
|
525
|
+
*
|
|
526
|
+
* Path params:
|
|
527
|
+
* - ticketNumber: e.g., "BUG-2025-0001" or "FB-2025-0001"
|
|
528
|
+
*
|
|
529
|
+
* Body (JSON):
|
|
530
|
+
* - archived: boolean (true to archive, false to unarchive)
|
|
531
|
+
*
|
|
532
|
+
* Headers:
|
|
533
|
+
* - Authorization: Bearer <api-key>
|
|
534
|
+
* - Content-Type: application/json
|
|
535
|
+
*
|
|
536
|
+
* Returns:
|
|
537
|
+
* - 200: { success: true, ticketNumber: "...", isArchived: true/false }
|
|
538
|
+
* - 400: { error: "Bad request" }
|
|
539
|
+
* - 401: { error: "Unauthorized" }
|
|
540
|
+
* - 404: { error: "Not found" }
|
|
541
|
+
*/
|
|
542
|
+
router.route({
|
|
543
|
+
path: `${prefix}/api/items/{ticketNumber}/archive`,
|
|
544
|
+
method: "PATCH",
|
|
545
|
+
handler: httpAction(async (ctx, request) => {
|
|
546
|
+
const authHeader = request.headers.get("Authorization");
|
|
547
|
+
const isValid = await validateApiKey(ctx, authHeader);
|
|
548
|
+
|
|
549
|
+
if (!isValid) {
|
|
550
|
+
return new Response(
|
|
551
|
+
JSON.stringify({ error: "Unauthorized", message: "Invalid or missing API key" }),
|
|
552
|
+
{ status: 401, headers: { "Content-Type": "application/json" } }
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Extract ticket number from URL
|
|
557
|
+
const url = new URL(request.url);
|
|
558
|
+
const pathParts = url.pathname.split("/");
|
|
559
|
+
// Path is /api/items/{ticketNumber}/archive, so ticketNumber is second to last
|
|
560
|
+
const ticketNumber = pathParts[pathParts.length - 2]?.toUpperCase();
|
|
561
|
+
|
|
562
|
+
if (!ticketNumber) {
|
|
563
|
+
return new Response(
|
|
564
|
+
JSON.stringify({ error: "Bad request", message: "Missing ticket number" }),
|
|
565
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const isBug = ticketNumber.startsWith("BUG-");
|
|
570
|
+
const isFeedback = ticketNumber.startsWith("FB-");
|
|
571
|
+
|
|
572
|
+
if (!isBug && !isFeedback) {
|
|
573
|
+
return new Response(
|
|
574
|
+
JSON.stringify({ error: "Bad request", message: "Invalid ticket number format. Expected BUG-YYYY-NNNN or FB-YYYY-NNNN" }),
|
|
575
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Parse request body
|
|
580
|
+
let body: { archived?: boolean };
|
|
581
|
+
try {
|
|
582
|
+
body = await request.json();
|
|
583
|
+
} catch {
|
|
584
|
+
return new Response(
|
|
585
|
+
JSON.stringify({ error: "Bad request", message: "Invalid JSON body" }),
|
|
586
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (typeof body.archived !== "boolean") {
|
|
591
|
+
return new Response(
|
|
592
|
+
JSON.stringify({ error: "Bad request", message: "Missing or invalid 'archived' boolean in request body" }),
|
|
593
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Update the archive status
|
|
598
|
+
let result;
|
|
599
|
+
if (isBug) {
|
|
600
|
+
result = await ctx.runMutation(internal.bugReports.setArchivedByTicketNumber, {
|
|
601
|
+
ticketNumber,
|
|
602
|
+
isArchived: body.archived,
|
|
603
|
+
});
|
|
604
|
+
} else {
|
|
605
|
+
result = await ctx.runMutation(internal.feedback.setArchivedByTicketNumber, {
|
|
606
|
+
ticketNumber,
|
|
607
|
+
isArchived: body.archived,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (!result) {
|
|
612
|
+
return new Response(
|
|
613
|
+
JSON.stringify({ error: "Not found", message: `Item ${ticketNumber} not found` }),
|
|
614
|
+
{ status: 404, headers: { "Content-Type": "application/json" } }
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return new Response(JSON.stringify(result), {
|
|
619
|
+
status: 200,
|
|
620
|
+
headers: { "Content-Type": "application/json" },
|
|
621
|
+
});
|
|
622
|
+
}),
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Create a standalone router for backwards compatibility / standalone usage
|
|
627
|
+
const http = httpRouter();
|
|
628
|
+
registerFeedbackRoutes(http);
|
|
629
|
+
|
|
630
|
+
export default http;
|
package/src/convex/index.ts
CHANGED
|
@@ -10,6 +10,11 @@ export * as feedback from './feedback';
|
|
|
10
10
|
export * as supportTeams from './supportTeams';
|
|
11
11
|
export * as inputRequests from './inputRequests';
|
|
12
12
|
export * as agents from './agents';
|
|
13
|
+
export * as apiKeys from './apiKeys';
|
|
14
|
+
export * as prompts from './prompts';
|
|
15
|
+
|
|
16
|
+
// Export HTTP route registration helper
|
|
17
|
+
export { registerFeedbackRoutes, type RegisterFeedbackRoutesOptions } from './http';
|
|
13
18
|
|
|
14
19
|
// Re-export schema types and validators
|
|
15
20
|
export {
|
|
@@ -20,6 +25,7 @@ export {
|
|
|
20
25
|
feedbackStatusValidator,
|
|
21
26
|
reporterTypeValidator,
|
|
22
27
|
effortValidator,
|
|
28
|
+
counterTypeValidator,
|
|
23
29
|
type BugSeverity,
|
|
24
30
|
type BugStatus,
|
|
25
31
|
type FeedbackType,
|
|
@@ -27,4 +33,9 @@ export {
|
|
|
27
33
|
type FeedbackStatus,
|
|
28
34
|
type ReporterType,
|
|
29
35
|
type Effort,
|
|
36
|
+
type CounterType,
|
|
30
37
|
} from './schema';
|
|
38
|
+
|
|
39
|
+
// Re-export prompt template type
|
|
40
|
+
export type { PromptTemplate } from './prompts';
|
|
41
|
+
export { promptTemplateValidator } from './prompts';
|