@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.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Unit tests for HTTP API - Ticket Number Format Validation
3
+ *
4
+ * Note: Testing Convex component packages with convex-test requires special setup
5
+ * due to the @convex-dev/agent dependency. These tests verify ticket number formats
6
+ * used by the HTTP API endpoints.
7
+ *
8
+ * For full integration testing of queries/mutations, run the component in a real
9
+ * Convex deployment or see the main project's test patterns.
10
+ *
11
+ * Prompt generation tests are in prompts.test.ts
12
+ * API key format tests are in apiKeys.test.ts
13
+ */
14
+ import { describe, it, expect } from "vitest";
15
+
16
+ describe("HTTP API - Ticket Number Format", () => {
17
+ describe("Bug Report Ticket Numbers", () => {
18
+ it("follows BUG-YYYY-NNNN format", () => {
19
+ const validPattern = /^BUG-\d{4}-\d{4}$/;
20
+
21
+ expect("BUG-2025-0001").toMatch(validPattern);
22
+ expect("BUG-2025-0123").toMatch(validPattern);
23
+ expect("BUG-2024-9999").toMatch(validPattern);
24
+ });
25
+
26
+ it("pads sequence number to 4 digits", () => {
27
+ const formatTicketNumber = (year: number, seq: number) =>
28
+ `BUG-${year}-${seq.toString().padStart(4, "0")}`;
29
+
30
+ expect(formatTicketNumber(2025, 1)).toBe("BUG-2025-0001");
31
+ expect(formatTicketNumber(2025, 42)).toBe("BUG-2025-0042");
32
+ expect(formatTicketNumber(2025, 999)).toBe("BUG-2025-0999");
33
+ expect(formatTicketNumber(2025, 1234)).toBe("BUG-2025-1234");
34
+ });
35
+ });
36
+
37
+ describe("Feedback Ticket Numbers", () => {
38
+ it("follows FB-YYYY-NNNN format", () => {
39
+ const validPattern = /^FB-\d{4}-\d{4}$/;
40
+
41
+ expect("FB-2025-0001").toMatch(validPattern);
42
+ expect("FB-2025-0123").toMatch(validPattern);
43
+ expect("FB-2024-9999").toMatch(validPattern);
44
+ });
45
+
46
+ it("pads sequence number to 4 digits", () => {
47
+ const formatTicketNumber = (year: number, seq: number) =>
48
+ `FB-${year}-${seq.toString().padStart(4, "0")}`;
49
+
50
+ expect(formatTicketNumber(2025, 1)).toBe("FB-2025-0001");
51
+ expect(formatTicketNumber(2025, 42)).toBe("FB-2025-0042");
52
+ expect(formatTicketNumber(2025, 999)).toBe("FB-2025-0999");
53
+ expect(formatTicketNumber(2025, 1234)).toBe("FB-2025-1234");
54
+ });
55
+ });
56
+
57
+ describe("Ticket Number Parsing", () => {
58
+ it("can identify bug tickets by prefix", () => {
59
+ const ticketNumber = "BUG-2025-0001";
60
+ expect(ticketNumber.startsWith("BUG-")).toBe(true);
61
+ expect(ticketNumber.startsWith("FB-")).toBe(false);
62
+ });
63
+
64
+ it("can identify feedback tickets by prefix", () => {
65
+ const ticketNumber = "FB-2025-0001";
66
+ expect(ticketNumber.startsWith("FB-")).toBe(true);
67
+ expect(ticketNumber.startsWith("BUG-")).toBe(false);
68
+ });
69
+
70
+ it("normalizes ticket numbers to uppercase", () => {
71
+ const lowercase = "bug-2025-0001";
72
+ const normalized = lowercase.toUpperCase();
73
+ expect(normalized).toBe("BUG-2025-0001");
74
+ });
75
+ });
76
+ });
@@ -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;