import { v } from "convex/values"; import { mutation, query, internalMutation, internalQuery } from "./_generated/server"; import { internal } from "./_generated/api"; import { feedbackTypeValidator, feedbackPriorityValidator, feedbackStatusValidator, reporterTypeValidator, effortValidator, } from "./schema"; // Return type for feedback records const feedbackReturnValidator = v.object({ _id: v.id("feedback"), _creationTime: v.number(), type: feedbackTypeValidator, title: v.string(), description: v.string(), priority: feedbackPriorityValidator, status: feedbackStatusValidator, 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()), aiImpactAnalysis: v.optional(v.string()), aiActionItems: v.optional(v.array(v.string())), aiEstimatedEffort: v.optional(effortValidator), aiSuggestedPriority: v.optional(feedbackPriorityValidator), aiAnalyzedAt: v.optional(v.number()), aiThreadId: v.optional(v.string()), notificationsSentAt: v.optional(v.number()), ticketNumber: v.optional(v.string()), }); /** * Create new feedback. * This mutation is intentionally public (no auth required) because: * 1. We want to capture feedback even when auth might be broken * 2. The reporter info is passed explicitly from the client */ export const create = mutation({ args: { type: feedbackTypeValidator, title: v.string(), description: v.string(), priority: feedbackPriorityValidator, 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("feedback"), 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", "feedback").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: "feedback", year, nextNumber: 2, }); } const ticketNumber = `FB-${year}-${nextNumber.toString().padStart(4, "0")}`; const feedbackId = await ctx.db.insert("feedback", { type: args.type, title: args.title, description: args.description, priority: args.priority, 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) { try { await ctx.scheduler.runAfter(0, internal.agents.feedbackAgent.processFeedback, { feedbackId, // 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 feedbackId; }, }); /** * List feedback with optional filters. * By default, archived items are excluded unless includeArchived is true. */ export const list = query({ args: { status: v.optional(feedbackStatusValidator), type: v.optional(feedbackTypeValidator), priority: v.optional(feedbackPriorityValidator), includeArchived: v.optional(v.boolean()), limit: v.optional(v.number()), }, returns: v.array(feedbackReturnValidator), handler: async (ctx, args) => { const limit = args.limit ?? 50; const includeArchived = args.includeArchived ?? false; let results; if (args.status) { const status = args.status; results = await ctx.db .query("feedback") .withIndex("by_status", (q) => q.eq("status", status)) .order("desc") .take(limit * 2); } else if (args.type) { const type = args.type; results = await ctx.db .query("feedback") .withIndex("by_type", (q) => q.eq("type", type)) .order("desc") .take(limit * 2); } else if (args.priority) { const priority = args.priority; results = await ctx.db .query("feedback") .withIndex("by_priority", (q) => q.eq("priority", priority)) .order("desc") .take(limit * 2); } else { results = await ctx.db .query("feedback") .withIndex("by_created") .order("desc") .take(limit * 2); } // Filter out archived items unless includeArchived is true if (!includeArchived) { results = results.filter((r) => !r.isArchived); } return results.slice(0, limit); }, }); /** * Get a single feedback by ID. */ export const get = query({ args: { feedbackId: v.id("feedback"), }, returns: v.union(feedbackReturnValidator, v.null()), handler: async (ctx, args) => { return await ctx.db.get(args.feedbackId); }, }); /** * Update feedback status. */ export const updateStatus = mutation({ args: { feedbackId: v.id("feedback"), status: feedbackStatusValidator, }, returns: v.null(), handler: async (ctx, args) => { const feedback = await ctx.db.get(args.feedbackId); if (!feedback) { throw new Error("Feedback not found"); } await ctx.db.patch(args.feedbackId, { 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 feedback. */ export const archive = mutation({ args: { feedbackId: v.id("feedback"), }, returns: v.null(), handler: async (ctx, args) => { const feedback = await ctx.db.get(args.feedbackId); if (!feedback) { throw new Error("Feedback not found"); } await ctx.db.patch(args.feedbackId, { isArchived: true, updatedAt: Date.now(), }); return null; }, }); /** * Unarchive feedback. */ export const unarchive = mutation({ args: { feedbackId: v.id("feedback"), }, returns: v.null(), handler: async (ctx, args) => { const feedback = await ctx.db.get(args.feedbackId); if (!feedback) { throw new Error("Feedback not found"); } await ctx.db.patch(args.feedbackId, { isArchived: false, updatedAt: Date.now(), }); return null; }, }); /** * Get AI analysis for a specific feedback. */ export const getAiAnalysis = query({ args: { feedbackId: v.id("feedback"), }, returns: v.union( v.object({ aiSummary: v.union(v.string(), v.null()), aiImpactAnalysis: v.union(v.string(), v.null()), aiActionItems: v.union(v.array(v.string()), v.null()), aiEstimatedEffort: v.union(effortValidator, v.null()), aiSuggestedPriority: v.union(feedbackPriorityValidator, v.null()), aiAnalyzedAt: v.union(v.number(), v.null()), notificationsSentAt: v.union(v.number(), v.null()), }), v.null() ), handler: async (ctx, args) => { const feedback = await ctx.db.get(args.feedbackId); if (!feedback) { return null; } return { aiSummary: feedback.aiSummary ?? null, aiImpactAnalysis: feedback.aiImpactAnalysis ?? null, aiActionItems: feedback.aiActionItems ?? null, aiEstimatedEffort: feedback.aiEstimatedEffort ?? null, aiSuggestedPriority: feedback.aiSuggestedPriority ?? null, aiAnalyzedAt: feedback.aiAnalyzedAt ?? null, notificationsSentAt: feedback.notificationsSentAt ?? null, }; }, }); /** * Internal mutation to update feedback with AI analysis results */ export const updateWithAnalysis = internalMutation({ args: { feedbackId: v.id("feedback"), aiSummary: v.string(), aiImpactAnalysis: v.string(), aiActionItems: v.array(v.string()), aiEstimatedEffort: effortValidator, aiSuggestedPriority: feedbackPriorityValidator, aiThreadId: v.string(), }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.feedbackId, { aiSummary: args.aiSummary, aiImpactAnalysis: args.aiImpactAnalysis, aiActionItems: args.aiActionItems, aiEstimatedEffort: args.aiEstimatedEffort, aiSuggestedPriority: args.aiSuggestedPriority, aiThreadId: args.aiThreadId, aiAnalyzedAt: Date.now(), updatedAt: Date.now(), }); return null; }, }); /** * Internal mutation to mark notifications as sent */ export const markNotificationsSent = internalMutation({ args: { feedbackId: v.id("feedback"), }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.feedbackId, { notificationsSentAt: Date.now(), updatedAt: Date.now(), }); return null; }, }); /** * Internal query to list feedback (for HTTP API). */ export const listInternal = internalQuery({ args: { status: v.optional(feedbackStatusValidator), type: v.optional(feedbackTypeValidator), priority: v.optional(feedbackPriorityValidator), includeArchived: v.optional(v.boolean()), limit: v.optional(v.number()), }, returns: v.array(feedbackReturnValidator), handler: async (ctx, args) => { const limit = args.limit ?? 50; const includeArchived = args.includeArchived ?? false; let results; if (args.status) { const status = args.status; results = await ctx.db .query("feedback") .withIndex("by_status", (q) => q.eq("status", status)) .order("desc") .take(limit * 2); } else if (args.type) { const type = args.type; results = await ctx.db .query("feedback") .withIndex("by_type", (q) => q.eq("type", type)) .order("desc") .take(limit * 2); } else if (args.priority) { const priority = args.priority; results = await ctx.db .query("feedback") .withIndex("by_priority", (q) => q.eq("priority", priority)) .order("desc") .take(limit * 2); } else { results = await ctx.db .query("feedback") .withIndex("by_created") .order("desc") .take(limit * 2); } if (!includeArchived) { results = results.filter((r) => !r.isArchived); } return results.slice(0, limit); }, }); /** * Public mutation to archive/unarchive feedback 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 feedback = await ctx.db .query("feedback") .withIndex("by_ticket_number", (q) => q.eq("ticketNumber", args.ticketNumber)) .unique(); if (!feedback) { return null; } await ctx.db.patch(feedback._id, { isArchived: args.isArchived, updatedAt: Date.now(), }); return { success: true, ticketNumber: args.ticketNumber, isArchived: args.isArchived }; }, }); /** * Public mutation to update feedback status by ticket number. * Used by consumer apps via component API and HTTP API. */ export const updateStatusByTicketNumber = mutation({ args: { ticketNumber: v.string(), status: feedbackStatusValidator, }, returns: v.union(v.object({ success: v.boolean(), ticketNumber: v.string() }), v.null()), handler: async (ctx, args) => { const feedback = await ctx.db .query("feedback") .withIndex("by_ticket_number", (q) => q.eq("ticketNumber", args.ticketNumber)) .unique(); if (!feedback) { return null; } await ctx.db.patch(feedback._id, { status: args.status, updatedAt: Date.now(), }); return { success: true, ticketNumber: args.ticketNumber }; }, });