import { v } from "convex/values"; import { mutation, query, internalMutation, internalQuery } from "./_generated/server"; import { internal } from "./_generated/api"; import { bugSeverityValidator, bugStatusValidator, reporterTypeValidator, effortValidator, } from "./schema"; // Return validator for bug reports const bugReportReturnValidator = v.object({ _id: v.id("bugReports"), _creationTime: v.number(), title: v.string(), description: v.string(), severity: bugSeverityValidator, status: bugStatusValidator, isArchived: v.optional(v.boolean()), reporterType: reporterTypeValidator, reporterId: v.string(), reporterEmail: v.string(), reporterName: v.string(), url: v.string(), route: v.optional(v.string()), browserInfo: v.string(), consoleErrors: v.optional(v.string()), screenshotStorageId: v.optional(v.id("_storage")), viewportWidth: v.number(), viewportHeight: v.number(), networkState: v.string(), createdAt: v.number(), updatedAt: v.number(), // AI Analysis fields aiSummary: v.optional(v.string()), aiRootCauseAnalysis: v.optional(v.string()), aiReproductionSteps: v.optional(v.array(v.string())), aiSuggestedFix: v.optional(v.string()), aiEstimatedEffort: v.optional(effortValidator), aiSuggestedSeverity: v.optional(bugSeverityValidator), aiAnalyzedAt: v.optional(v.number()), aiThreadId: v.optional(v.string()), notificationsSentAt: v.optional(v.number()), ticketNumber: v.optional(v.string()), }); /** * Create a new bug report. * This mutation is intentionally public (no auth required) because: * 1. We want to capture bugs even when auth might be broken * 2. The reporter info is passed explicitly from the client */ export const create = mutation({ args: { title: v.string(), description: v.string(), severity: bugSeverityValidator, reporterType: reporterTypeValidator, reporterId: v.string(), reporterEmail: v.string(), reporterName: v.string(), url: v.string(), route: v.optional(v.string()), browserInfo: v.string(), consoleErrors: v.optional(v.string()), screenshotStorageId: v.optional(v.id("_storage")), viewportWidth: v.number(), viewportHeight: v.number(), networkState: v.string(), // When true, skip auto-scheduling of AI analysis/email notifications // Use this when the parent app will handle processing with its own API keys skipAutoProcess: v.optional(v.boolean()), // Optional API keys - pass from parent app since components don't inherit env vars openRouterApiKey: v.optional(v.string()), resendApiKey: v.optional(v.string()), resendFromEmail: v.optional(v.string()), }, returns: v.id("bugReports"), handler: async (ctx, args) => { const now = Date.now(); // Generate ticket number const year = new Date().getFullYear(); const counter = await ctx.db .query("ticketCounters") .withIndex("by_type_year", (q) => q.eq("type", "bug").eq("year", year)) .unique(); let nextNumber: number; if (counter) { nextNumber = counter.nextNumber; await ctx.db.patch(counter._id, { nextNumber: nextNumber + 1 }); } else { nextNumber = 1; await ctx.db.insert("ticketCounters", { type: "bug", year, nextNumber: 2, }); } const ticketNumber = `BUG-${year}-${nextNumber.toString().padStart(4, "0")}`; const reportId = await ctx.db.insert("bugReports", { title: args.title, description: args.description, severity: args.severity, status: "open", reporterType: args.reporterType, reporterId: args.reporterId, reporterEmail: args.reporterEmail, reporterName: args.reporterName, url: args.url, route: args.route, browserInfo: args.browserInfo, consoleErrors: args.consoleErrors, screenshotStorageId: args.screenshotStorageId, viewportWidth: args.viewportWidth, viewportHeight: args.viewportHeight, networkState: args.networkState, ticketNumber, createdAt: now, updatedAt: now, }); // Schedule AI analysis and email notifications (runs immediately) // Skip if parent app will handle processing with its own API keys if (!args.skipAutoProcess) { // This uses a try-catch to gracefully handle if agents aren't configured try { await ctx.scheduler.runAfter(0, internal.agents.bugReportAgent.processBugReport, { bugReportId: reportId, // Forward API keys from parent app (components don't inherit env vars) openRouterApiKey: args.openRouterApiKey, resendApiKey: args.resendApiKey, resendFromEmail: args.resendFromEmail, }); } catch { // AI agent not available, continue without analysis console.log("AI agent not configured, skipping analysis"); } } return reportId; }, }); /** * List bug reports with optional filters. * By default, archived items are excluded unless includeArchived is true. */ export const list = query({ args: { status: v.optional(bugStatusValidator), severity: v.optional(bugSeverityValidator), includeArchived: v.optional(v.boolean()), limit: v.optional(v.number()), }, returns: v.array(bugReportReturnValidator), handler: async (ctx, args) => { const limit = args.limit ?? 50; const includeArchived = args.includeArchived ?? false; let reports; if (args.status) { const status = args.status; reports = await ctx.db .query("bugReports") .withIndex("by_status", (q) => q.eq("status", status)) .order("desc") .take(limit * 2); } else if (args.severity) { const severity = args.severity; reports = await ctx.db .query("bugReports") .withIndex("by_severity", (q) => q.eq("severity", severity)) .order("desc") .take(limit * 2); } else { reports = await ctx.db .query("bugReports") .withIndex("by_created") .order("desc") .take(limit * 2); } // Filter out archived items unless includeArchived is true if (!includeArchived) { reports = reports.filter((r) => !r.isArchived); } return reports.slice(0, limit); }, }); /** * Get a single bug report by ID. */ export const get = query({ args: { reportId: v.id("bugReports"), }, returns: v.union(bugReportReturnValidator, v.null()), handler: async (ctx, args) => { return await ctx.db.get(args.reportId); }, }); /** * Update bug report status. */ export const updateStatus = mutation({ args: { reportId: v.id("bugReports"), status: bugStatusValidator, }, returns: v.null(), handler: async (ctx, args) => { const report = await ctx.db.get(args.reportId); if (!report) { throw new Error("Bug report not found"); } await ctx.db.patch(args.reportId, { status: args.status, updatedAt: Date.now(), }); return null; }, }); /** * Generate upload URL for screenshot. */ export const generateUploadUrl = mutation({ args: {}, returns: v.string(), handler: async (ctx) => { return await ctx.storage.generateUploadUrl(); }, }); /** * Get screenshot URL from storage ID. */ export const getScreenshotUrl = query({ args: { storageId: v.id("_storage"), }, returns: v.union(v.string(), v.null()), handler: async (ctx, args) => { return await ctx.storage.getUrl(args.storageId); }, }); /** * Archive a bug report. */ export const archive = mutation({ args: { reportId: v.id("bugReports"), }, returns: v.null(), handler: async (ctx, args) => { const report = await ctx.db.get(args.reportId); if (!report) { throw new Error("Bug report not found"); } await ctx.db.patch(args.reportId, { isArchived: true, updatedAt: Date.now(), }); return null; }, }); /** * Unarchive a bug report. */ export const unarchive = mutation({ args: { reportId: v.id("bugReports"), }, returns: v.null(), handler: async (ctx, args) => { const report = await ctx.db.get(args.reportId); if (!report) { throw new Error("Bug report not found"); } await ctx.db.patch(args.reportId, { isArchived: false, updatedAt: Date.now(), }); return null; }, }); /** * Get AI analysis for a specific bug report. */ export const getAiAnalysis = query({ args: { reportId: v.id("bugReports"), }, returns: v.union( v.object({ aiSummary: v.union(v.string(), v.null()), aiRootCauseAnalysis: v.union(v.string(), v.null()), aiReproductionSteps: v.union(v.array(v.string()), v.null()), aiSuggestedFix: v.union(v.string(), v.null()), aiEstimatedEffort: v.union(effortValidator, v.null()), aiSuggestedSeverity: v.union(bugSeverityValidator, v.null()), aiAnalyzedAt: v.union(v.number(), v.null()), notificationsSentAt: v.union(v.number(), v.null()), }), v.null() ), handler: async (ctx, args) => { const report = await ctx.db.get(args.reportId); if (!report) { return null; } return { aiSummary: report.aiSummary ?? null, aiRootCauseAnalysis: report.aiRootCauseAnalysis ?? null, aiReproductionSteps: report.aiReproductionSteps ?? null, aiSuggestedFix: report.aiSuggestedFix ?? null, aiEstimatedEffort: report.aiEstimatedEffort ?? null, aiSuggestedSeverity: report.aiSuggestedSeverity ?? null, aiAnalyzedAt: report.aiAnalyzedAt ?? null, notificationsSentAt: report.notificationsSentAt ?? null, }; }, }); /** * Internal mutation to update bug report with AI analysis results */ export const updateWithAnalysis = internalMutation({ args: { bugReportId: v.id("bugReports"), aiSummary: v.string(), aiRootCauseAnalysis: v.string(), aiReproductionSteps: v.array(v.string()), aiSuggestedFix: v.string(), aiEstimatedEffort: effortValidator, aiSuggestedSeverity: bugSeverityValidator, aiThreadId: v.string(), }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.bugReportId, { aiSummary: args.aiSummary, aiRootCauseAnalysis: args.aiRootCauseAnalysis, aiReproductionSteps: args.aiReproductionSteps, aiSuggestedFix: args.aiSuggestedFix, aiEstimatedEffort: args.aiEstimatedEffort, aiSuggestedSeverity: args.aiSuggestedSeverity, aiThreadId: args.aiThreadId, aiAnalyzedAt: Date.now(), updatedAt: Date.now(), }); return null; }, }); /** * Internal query to list bug reports (for HTTP API). */ export const listInternal = internalQuery({ args: { status: v.optional(bugStatusValidator), severity: v.optional(bugSeverityValidator), includeArchived: v.optional(v.boolean()), limit: v.optional(v.number()), }, returns: v.array(bugReportReturnValidator), handler: async (ctx, args) => { const limit = args.limit ?? 50; const includeArchived = args.includeArchived ?? false; let reports; if (args.status) { const status = args.status; reports = await ctx.db .query("bugReports") .withIndex("by_status", (q) => q.eq("status", status)) .order("desc") .take(limit * 2); } else if (args.severity) { const severity = args.severity; reports = await ctx.db .query("bugReports") .withIndex("by_severity", (q) => q.eq("severity", severity)) .order("desc") .take(limit * 2); } else { reports = await ctx.db .query("bugReports") .withIndex("by_created") .order("desc") .take(limit * 2); } if (!includeArchived) { reports = reports.filter((r) => !r.isArchived); } return reports.slice(0, limit); }, }); /** * Public mutation to update bug report status by ticket number. * Used by consumer apps via component API and HTTP API. */ export const updateStatusByTicketNumber = mutation({ args: { ticketNumber: v.string(), status: bugStatusValidator, }, returns: v.union(v.object({ success: v.boolean(), ticketNumber: v.string() }), v.null()), handler: async (ctx, args) => { const report = await ctx.db .query("bugReports") .withIndex("by_ticket_number", (q) => q.eq("ticketNumber", args.ticketNumber)) .unique(); if (!report) { return null; } await ctx.db.patch(report._id, { status: args.status, updatedAt: Date.now(), }); return { success: true, ticketNumber: args.ticketNumber }; }, }); /** * Public mutation to archive/unarchive bug report by ticket number. * Used by consumer apps via component API and HTTP API. */ export const setArchivedByTicketNumber = mutation({ args: { ticketNumber: v.string(), isArchived: v.boolean(), }, returns: v.union(v.object({ success: v.boolean(), ticketNumber: v.string(), isArchived: v.boolean() }), v.null()), handler: async (ctx, args) => { const report = await ctx.db .query("bugReports") .withIndex("by_ticket_number", (q) => q.eq("ticketNumber", args.ticketNumber)) .unique(); if (!report) { return null; } await ctx.db.patch(report._id, { isArchived: args.isArchived, updatedAt: Date.now(), }); return { success: true, ticketNumber: args.ticketNumber, isArchived: args.isArchived }; }, }); /** * Internal mutation to mark notifications as sent */ export const markNotificationsSent = internalMutation({ args: { bugReportId: v.id("bugReports"), }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.bugReportId, { notificationsSentAt: Date.now(), updatedAt: Date.now(), }); return null; }, });