import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { getAuthUserId } from "@convex-dev/auth/server"; const opportunityInput = v.object({ url: v.string(), platform: v.string(), title: v.string(), snippet: v.string(), relevanceScore: v.number(), intent: v.string(), suggestedApproach: v.string(), matchedKeywords: v.array(v.string()), matchedProblems: v.array(v.string()), softPitch: v.boolean(), }); export const listByProject = query({ args: { projectId: v.id("projects"), status: v.optional(v.string()), intent: v.optional(v.string()), searchJobId: v.optional(v.id("searchJobs")), minScore: v.optional(v.number()), limit: v.optional(v.number()), }, handler: async (ctx, args) => { const userId = await getAuthUserId(ctx); if (!userId) return []; const project = await ctx.db.get(args.projectId); if (!project || project.userId !== userId) return []; const limit = args.limit ?? 50; let queryBuilder = args.searchJobId ? ctx.db .query("opportunities") .withIndex("by_project_searchJob", (q) => q.eq("projectId", args.projectId).eq("searchJobId", args.searchJobId!) ) : args.status ? ctx.db .query("opportunities") .withIndex("by_project_status", (q) => q.eq("projectId", args.projectId).eq("status", args.status!) ) : ctx.db .query("opportunities") .withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId) ); if (args.intent) { queryBuilder = queryBuilder.filter((q) => q.eq(q.field("intent"), args.intent) ); } if (args.minScore !== undefined) { queryBuilder = queryBuilder.filter((q) => q.gte(q.field("relevanceScore"), args.minScore) ); } const results = await queryBuilder.order("desc").collect(); return results.slice(0, limit); }, }); export const upsertBatch = mutation({ args: { projectId: v.id("projects"), analysisId: v.optional(v.id("analyses")), searchJobId: v.optional(v.id("searchJobs")), opportunities: v.array(opportunityInput), }, handler: async (ctx, args) => { const userId = await getAuthUserId(ctx); if (!userId) throw new Error("Unauthorized"); const project = await ctx.db.get(args.projectId); if (!project || project.userId !== userId) { throw new Error("Project not found or unauthorized"); } let created = 0; let updated = 0; const now = Date.now(); for (const opp of args.opportunities) { const existing = await ctx.db .query("opportunities") .withIndex("by_project_url", (q) => q.eq("projectId", args.projectId).eq("url", opp.url) ) .first(); if (existing) { await ctx.db.patch(existing._id, { analysisId: args.analysisId, searchJobId: args.searchJobId, platform: opp.platform, title: opp.title, snippet: opp.snippet, relevanceScore: opp.relevanceScore, intent: opp.intent, suggestedApproach: opp.suggestedApproach, matchedKeywords: opp.matchedKeywords, matchedProblems: opp.matchedProblems, softPitch: opp.softPitch, updatedAt: now, }); updated += 1; } else { await ctx.db.insert("opportunities", { projectId: args.projectId, analysisId: args.analysisId, searchJobId: args.searchJobId, url: opp.url, platform: opp.platform, title: opp.title, snippet: opp.snippet, relevanceScore: opp.relevanceScore, intent: opp.intent, status: "new", suggestedApproach: opp.suggestedApproach, matchedKeywords: opp.matchedKeywords, matchedProblems: opp.matchedProblems, softPitch: opp.softPitch, createdAt: now, updatedAt: now, }); created += 1; } const seenExisting = await ctx.db .query("seenUrls") .withIndex("by_project_url", (q) => q.eq("projectId", args.projectId).eq("url", opp.url) ) .first(); if (seenExisting) { await ctx.db.patch(seenExisting._id, { lastSeenAt: now, source: "opportunities", }); } else { await ctx.db.insert("seenUrls", { projectId: args.projectId, url: opp.url, firstSeenAt: now, lastSeenAt: now, source: "opportunities", }); } } return { created, updated }; }, }); export const updateStatus = mutation({ args: { id: v.id("opportunities"), status: v.string(), notes: v.optional(v.string()), tags: v.optional(v.array(v.string())), }, handler: async (ctx, args) => { const userId = await getAuthUserId(ctx); if (!userId) throw new Error("Unauthorized"); const opportunity = await ctx.db.get(args.id); if (!opportunity) throw new Error("Opportunity not found"); const project = await ctx.db.get(opportunity.projectId); if (!project || project.userId !== userId) { throw new Error("Project not found or unauthorized"); } const now = Date.now(); const patch: Record = { status: args.status, notes: args.notes, tags: args.tags, updatedAt: now, }; if (args.status === "sent") patch.sentAt = now; if (args.status === "archived") patch.archivedAt = now; await ctx.db.patch(args.id, patch); }, }); export const countByDay = query({ args: { projectId: v.id("projects"), days: v.optional(v.number()), metric: v.union( v.literal("created"), v.literal("sent"), v.literal("archived") ), }, handler: async (ctx, args) => { const userId = await getAuthUserId(ctx); if (!userId) return []; const project = await ctx.db.get(args.projectId); if (!project || project.userId !== userId) return []; const days = args.days ?? 14; const now = Date.now(); const start = now - days * 24 * 60 * 60 * 1000; const results = await ctx.db .query("opportunities") .withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId)) .order("desc") .collect(); const counts = new Map(); const toDateKey = (timestamp: number) => new Date(timestamp).toISOString().slice(0, 10); for (const opp of results) { let timestamp: number | null = null; if (args.metric === "created") { timestamp = opp.createdAt; } else if (args.metric === "sent") { timestamp = opp.sentAt ?? (opp.status === "sent" || opp.status === "converted" ? opp.updatedAt : null); } else if (args.metric === "archived") { timestamp = opp.archivedAt ?? (opp.status === "archived" || opp.status === "ignored" ? opp.updatedAt : null); } if (!timestamp || timestamp < start) continue; const key = toDateKey(timestamp); counts.set(key, (counts.get(key) ?? 0) + 1); } const series = []; for (let i = days - 1; i >= 0; i -= 1) { const date = new Date(now - i * 24 * 60 * 60 * 1000) .toISOString() .slice(0, 10); series.push({ date, count: counts.get(date) ?? 0 }); } return series; }, });