import { httpRouter, HttpRouter } from "convex/server"; import { httpAction } from "./_generated/server"; import { api, internal } from "./_generated/api"; import { generateBugPrompt, generateFeedbackPrompt, PromptTemplate } from "./prompts"; import type { RegisterFeedbackRoutesOptions } from "../types"; export type { RegisterFeedbackRoutesOptions }; // Type for the component API reference // Note: The component exposes public functions for consumer apps type ComponentApi = { apiKeys: { validate: unknown; }; prompts: { getBugByTicketNumber: unknown; getFeedbackByTicketNumber: unknown; }; bugReports: { list: unknown; updateStatusByTicketNumber: unknown; setArchivedByTicketNumber: unknown; }; feedback: { list: unknown; updateStatusByTicketNumber: unknown; setArchivedByTicketNumber: unknown; }; }; /** * Validate API key from Authorization header */ async function validateApiKey( ctx: { runQuery: (fn: unknown, args: unknown) => Promise; runMutation?: (fn: unknown, args: unknown) => Promise }, authHeader: string | null, componentApi?: ComponentApi ): Promise { if (!authHeader) { return false; } // Expect: "Bearer fb_xxxxx" const match = authHeader.match(/^Bearer\s+(.+)$/i); if (!match) { return false; } const key = match[1]; // Use component API if provided (for consumer apps), otherwise use internal API (for standalone usage) // Note: Component exposes 'validate' with { apiKey }, internal uses 'validateKey' with { key } if (componentApi) { return (await ctx.runQuery(componentApi.apiKeys.validate, { apiKey: key })) as boolean; } return (await ctx.runQuery(internal.apiKeys.validateKey, { key })) as boolean; } /** * Parse template from query string */ function parseTemplate(url: URL): PromptTemplate { const template = url.searchParams.get("template"); if (template === "fix" || template === "implement" || template === "analyze" || template === "codebuff") { return template; } // Return undefined to let the caller decide the default return "fix"; } /** * Extract ticket number from URL path. * Supports paths like: * - /api/items/BUG-2025-0001 * - /api/items/BUG-2025-0001/status * - /api/items/BUG-2025-0001/archive * - /api/prompt/BUG-2025-0001 */ function extractTicketNumber(pathname: string): { ticketNumber: string | undefined; action: string | undefined } { const pathParts = pathname.split("/").filter(Boolean); // Find index of 'items' or 'prompt' and get the next segment const itemsIndex = pathParts.indexOf("items"); const promptIndex = pathParts.indexOf("prompt"); if (itemsIndex >= 0) { const ticketNumber = pathParts[itemsIndex + 1]?.toUpperCase(); const action = pathParts[itemsIndex + 2]; // 'status' or 'archive' or undefined return { ticketNumber, action }; } if (promptIndex >= 0) { const ticketNumber = pathParts[promptIndex + 1]?.toUpperCase(); return { ticketNumber, action: undefined }; } return { ticketNumber: undefined, action: undefined }; } /** * Register all feedback HTTP routes on the provided router. * * This allows consumer apps to integrate the feedback API into their own HTTP router. * * NOTE: Convex httpRouter does NOT support dynamic path parameters like :id or {id}. * We use pathPrefix to match routes with dynamic segments and parse them in the handler. * * @example * ```typescript * // In your app's convex/http.ts: * import { httpRouter } from "convex/server"; * import { registerFeedbackRoutes } from "@convex-dev/feedback/convex"; * * const http = httpRouter(); * * // Register feedback routes with optional prefix * registerFeedbackRoutes(http, { pathPrefix: "/feedback" }); * * // Your other routes... * http.route({ path: "/api/health", method: "GET", handler: ... }); * * export default http; * ``` * * Registered routes: * - GET {prefix}/api/prompt/{ticketNumber} - Fetch AI prompt for a ticket * - GET {prefix}/api/items - List bug reports and feedback * - GET {prefix}/api/items/{ticketNumber} - Get a single item * - PATCH {prefix}/api/items/{ticketNumber}/status - Update item status * - PATCH {prefix}/api/items/{ticketNumber}/archive - Archive/unarchive item * * @param router - The HttpRouter to register routes on * @param options - Optional configuration */ export function registerFeedbackRoutes( router: HttpRouter, options: RegisterFeedbackRoutesOptions = {} ): void { const prefix = options.pathPrefix ?? ""; const componentApi = options.component as ComponentApi | undefined; // GET /api/prompt/{ticketNumber} - using pathPrefix since Convex doesn't support path params router.route({ pathPrefix: `${prefix}/api/prompt/`, method: "GET", handler: httpAction(async (ctx, request) => { // Validate API key const authHeader = request.headers.get("Authorization"); const isValid = await validateApiKey(ctx, authHeader, componentApi); if (!isValid) { return new Response( JSON.stringify({ error: "Unauthorized", message: "Invalid or missing API key" }), { status: 401, headers: { "Content-Type": "application/json" } } ); } // Extract ticket number from URL const url = new URL(request.url); const { ticketNumber } = extractTicketNumber(url.pathname); if (!ticketNumber) { return new Response( JSON.stringify({ error: "Bad request", message: "Missing ticket number" }), { status: 400, headers: { "Content-Type": "application/json" } } ); } const template = parseTemplate(url); const isBug = ticketNumber.startsWith("BUG-"); const isFeedback = ticketNumber.startsWith("FB-"); if (!isBug && !isFeedback) { return new Response( JSON.stringify({ error: "Bad request", message: "Invalid ticket number format" }), { status: 400, headers: { "Content-Type": "application/json" } } ); } // Use component API if provided, otherwise use public API (for standalone usage) const getBugByTicketNumberFn = componentApi?.prompts.getBugByTicketNumber ?? api.prompts.getBugByTicketNumber; const getFeedbackByTicketNumberFn = componentApi?.prompts.getFeedbackByTicketNumber ?? api.prompts.getFeedbackByTicketNumber; if (isBug) { const bug = await ctx.runQuery(getBugByTicketNumberFn, { ticketNumber }); if (!bug) { return new Response( JSON.stringify({ error: "Not found", message: `Bug report ${ticketNumber} not found` }), { status: 404, headers: { "Content-Type": "application/json" } } ); } const prompt = generateBugPrompt( { ticketNumber: bug.ticketNumber, title: bug.title, description: bug.description, severity: bug.severity, url: bug.url, route: bug.route, browserInfo: bug.browserInfo, consoleErrors: bug.consoleErrors, viewportWidth: bug.viewportWidth, viewportHeight: bug.viewportHeight, aiSummary: bug.aiSummary, aiRootCauseAnalysis: bug.aiRootCauseAnalysis, aiReproductionSteps: bug.aiReproductionSteps, aiSuggestedFix: bug.aiSuggestedFix, aiEstimatedEffort: bug.aiEstimatedEffort, createdAt: bug.createdAt, }, template ); return new Response( JSON.stringify({ ticketNumber: bug.ticketNumber, type: "bug", template, prompt, }), { status: 200, headers: { "Content-Type": "application/json" } } ); } // Feedback const feedback = await ctx.runQuery(getFeedbackByTicketNumberFn, { ticketNumber }); if (!feedback) { return new Response( JSON.stringify({ error: "Not found", message: `Feedback ${ticketNumber} not found` }), { status: 404, headers: { "Content-Type": "application/json" } } ); } // For feedback, default to "implement" instead of "fix" const feedbackTemplate = template === "fix" && !url.searchParams.has("template") ? "implement" : template; const prompt = generateFeedbackPrompt( { ticketNumber: feedback.ticketNumber, type: feedback.type, title: feedback.title, description: feedback.description, priority: feedback.priority, url: feedback.url, route: feedback.route, aiSummary: feedback.aiSummary, aiImpactAnalysis: feedback.aiImpactAnalysis, aiActionItems: feedback.aiActionItems, aiEstimatedEffort: feedback.aiEstimatedEffort, createdAt: feedback.createdAt, }, feedbackTemplate ); return new Response( JSON.stringify({ ticketNumber: feedback.ticketNumber, type: "feedback", template: feedbackTemplate, prompt, }), { status: 200, headers: { "Content-Type": "application/json" } } ); }), }); // GET /api/items/{ticketNumber} - using pathPrefix since Convex doesn't support path params // This handles both single item fetch and routes like /status and /archive router.route({ pathPrefix: `${prefix}/api/items/`, method: "GET", handler: httpAction(async (ctx, request) => { const authHeader = request.headers.get("Authorization"); const isValid = await validateApiKey(ctx, authHeader, componentApi); if (!isValid) { return new Response( JSON.stringify({ error: "Unauthorized", message: "Invalid or missing API key" }), { status: 401, headers: { "Content-Type": "application/json" } } ); } // Extract ticket number from URL path const url = new URL(request.url); const { ticketNumber } = extractTicketNumber(url.pathname); if (!ticketNumber) { return new Response( JSON.stringify({ error: "Bad request", message: "Missing ticket number" }), { status: 400, headers: { "Content-Type": "application/json" } } ); } const isBug = ticketNumber.startsWith("BUG-"); const isFeedback = ticketNumber.startsWith("FB-"); if (!isBug && !isFeedback) { return new Response( JSON.stringify({ error: "Bad request", message: "Invalid ticket number format. Expected BUG-YYYY-NNNN or FB-YYYY-NNNN" }), { status: 400, headers: { "Content-Type": "application/json" } } ); } // Use component API if provided, otherwise use public API (for standalone usage) const getBugByTicketNumberFn = componentApi?.prompts.getBugByTicketNumber ?? api.prompts.getBugByTicketNumber; const getFeedbackByTicketNumberFn = componentApi?.prompts.getFeedbackByTicketNumber ?? api.prompts.getFeedbackByTicketNumber; if (isBug) { const bug = await ctx.runQuery(getBugByTicketNumberFn, { ticketNumber }); if (!bug) { return new Response( JSON.stringify({ error: "Not found", message: `Bug report ${ticketNumber} not found` }), { status: 404, headers: { "Content-Type": "application/json" } } ); } return new Response( JSON.stringify({ type: "bug", ...bug }), { status: 200, headers: { "Content-Type": "application/json" } } ); } // Feedback const feedback = await ctx.runQuery(getFeedbackByTicketNumberFn, { ticketNumber }); if (!feedback) { return new Response( JSON.stringify({ error: "Not found", message: `Feedback ${ticketNumber} not found` }), { status: 404, headers: { "Content-Type": "application/json" } } ); } return new Response( JSON.stringify({ type: "feedback", ...feedback }), { status: 200, headers: { "Content-Type": "application/json" } } ); }), }); // GET /api/items - List all items (exact path match) router.route({ path: `${prefix}/api/items`, method: "GET", handler: httpAction(async (ctx, request) => { const authHeader = request.headers.get("Authorization"); const isValid = await validateApiKey(ctx, authHeader, componentApi); if (!isValid) { return new Response( JSON.stringify({ error: "Unauthorized", message: "Invalid or missing API key" }), { status: 401, headers: { "Content-Type": "application/json" } } ); } const url = new URL(request.url); const itemType = url.searchParams.get("itemType") ?? "all"; const status = url.searchParams.get("status"); const severity = url.searchParams.get("severity"); const priority = url.searchParams.get("priority"); const feedbackType = url.searchParams.get("type"); const limitParam = url.searchParams.get("limit"); const limit = limitParam ? parseInt(limitParam, 10) : 50; const includeArchived = url.searchParams.get("includeArchived") === "true"; const result: { bugs?: unknown[]; feedback?: unknown[] } = {}; // Use component API if provided, otherwise use public API (for standalone usage) const listBugsFn = componentApi?.bugReports.list ?? api.bugReports.list; const listFeedbackFn = componentApi?.feedback.list ?? api.feedback.list; // Fetch bugs if requested if (itemType === "bug" || itemType === "all") { const bugArgs: Record = { limit, includeArchived }; if (status && ["open", "in-progress", "resolved", "closed"].includes(status)) { bugArgs.status = status; } if (severity && ["low", "medium", "high", "critical"].includes(severity)) { bugArgs.severity = severity; } result.bugs = (await ctx.runQuery(listBugsFn, bugArgs)) as unknown[]; } // Fetch feedback if requested if (itemType === "feedback" || itemType === "all") { const feedbackArgs: Record = { limit, includeArchived }; if (status && ["open", "under_review", "planned", "in_progress", "completed", "declined"].includes(status)) { feedbackArgs.status = status; } if (priority && ["nice_to_have", "important", "critical"].includes(priority)) { feedbackArgs.priority = priority; } if (feedbackType && ["feature_request", "change_request", "general"].includes(feedbackType)) { feedbackArgs.type = feedbackType; } result.feedback = (await ctx.runQuery(listFeedbackFn, feedbackArgs)) as unknown[]; } return new Response(JSON.stringify(result), { status: 200, headers: { "Content-Type": "application/json" }, }); }), }); // PATCH /api/items/{ticketNumber}/status AND /api/items/{ticketNumber}/archive // Combined handler using pathPrefix since Convex doesn't support path params router.route({ pathPrefix: `${prefix}/api/items/`, method: "PATCH", handler: httpAction(async (ctx, request) => { const authHeader = request.headers.get("Authorization"); const isValid = await validateApiKey(ctx, authHeader, componentApi); if (!isValid) { return new Response( JSON.stringify({ error: "Unauthorized", message: "Invalid or missing API key" }), { status: 401, headers: { "Content-Type": "application/json" } } ); } // Extract ticket number and action from URL const url = new URL(request.url); const { ticketNumber, action } = extractTicketNumber(url.pathname); if (!ticketNumber) { return new Response( JSON.stringify({ error: "Bad request", message: "Missing ticket number" }), { status: 400, headers: { "Content-Type": "application/json" } } ); } if (action !== "status" && action !== "archive") { return new Response( JSON.stringify({ error: "Bad request", message: "Invalid action. Expected /status or /archive" }), { status: 400, headers: { "Content-Type": "application/json" } } ); } const isBug = ticketNumber.startsWith("BUG-"); const isFeedback = ticketNumber.startsWith("FB-"); if (!isBug && !isFeedback) { return new Response( JSON.stringify({ error: "Bad request", message: "Invalid ticket number format. Expected BUG-YYYY-NNNN or FB-YYYY-NNNN" }), { status: 400, headers: { "Content-Type": "application/json" } } ); } // Parse request body let body: { status?: string; archived?: boolean }; try { body = await request.json(); } catch { return new Response( JSON.stringify({ error: "Bad request", message: "Invalid JSON body" }), { status: 400, headers: { "Content-Type": "application/json" } } ); } // Handle status update if (action === "status") { if (!body.status) { return new Response( JSON.stringify({ error: "Bad request", message: "Missing status in request body" }), { status: 400, headers: { "Content-Type": "application/json" } } ); } // Validate status based on type const bugStatuses = ["open", "in-progress", "resolved", "closed"]; const feedbackStatuses = ["open", "under_review", "planned", "in_progress", "completed", "declined"]; if (isBug && !bugStatuses.includes(body.status)) { return new Response( JSON.stringify({ error: "Bad request", message: `Invalid bug status. Expected one of: ${bugStatuses.join(", ")}` }), { status: 400, headers: { "Content-Type": "application/json" } } ); } if (isFeedback && !feedbackStatuses.includes(body.status)) { return new Response( JSON.stringify({ error: "Bad request", message: `Invalid feedback status. Expected one of: ${feedbackStatuses.join(", ")}` }), { status: 400, headers: { "Content-Type": "application/json" } } ); } // Update the status using component API or public API let result; if (isBug) { const updateBugStatusFn = componentApi?.bugReports.updateStatusByTicketNumber ?? api.bugReports.updateStatusByTicketNumber; result = await ctx.runMutation(updateBugStatusFn, { ticketNumber, status: body.status as "open" | "in-progress" | "resolved" | "closed", }); } else { const updateFeedbackStatusFn = componentApi?.feedback.updateStatusByTicketNumber ?? api.feedback.updateStatusByTicketNumber; result = await ctx.runMutation(updateFeedbackStatusFn, { ticketNumber, status: body.status as "open" | "under_review" | "planned" | "in_progress" | "completed" | "declined", }); } if (!result) { return new Response( JSON.stringify({ error: "Not found", message: `Item ${ticketNumber} not found` }), { status: 404, headers: { "Content-Type": "application/json" } } ); } return new Response(JSON.stringify(result), { status: 200, headers: { "Content-Type": "application/json" }, }); } // Handle archive update if (action === "archive") { if (typeof body.archived !== "boolean") { return new Response( JSON.stringify({ error: "Bad request", message: "Missing or invalid 'archived' boolean in request body" }), { status: 400, headers: { "Content-Type": "application/json" } } ); } // Update the archive status using component API or public API let result; if (isBug) { const setArchivedBugFn = componentApi?.bugReports.setArchivedByTicketNumber ?? api.bugReports.setArchivedByTicketNumber; result = await ctx.runMutation(setArchivedBugFn, { ticketNumber, isArchived: body.archived, }); } else { const setArchivedFeedbackFn = componentApi?.feedback.setArchivedByTicketNumber ?? api.feedback.setArchivedByTicketNumber; result = await ctx.runMutation(setArchivedFeedbackFn, { ticketNumber, isArchived: body.archived, }); } if (!result) { return new Response( JSON.stringify({ error: "Not found", message: `Item ${ticketNumber} not found` }), { status: 404, headers: { "Content-Type": "application/json" } } ); } return new Response(JSON.stringify(result), { status: 200, headers: { "Content-Type": "application/json" }, }); } // Should never reach here return new Response( JSON.stringify({ error: "Bad request", message: "Invalid request" }), { status: 400, headers: { "Content-Type": "application/json" } } ); }), }); } // Create a standalone router for backwards compatibility / standalone usage const http = httpRouter(); registerFeedbackRoutes(http); export default http;