import { internal } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; import { internalMutation, mutation, query, } from "./_generated/server"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { v } from "convex/values"; const PAGE_SPEED_COUNTER_TEMPLATE = { leadsFound: 1, leadsCreated: 0, auditsCreated: 1, outreachPrepared: 0, errors: 0, }; type PageSpeedLead = Pick< Doc<"leads">, "_id" | "contactStatus" > & { websiteUrl: string; }; type AuditStartState = { leadId: Id<"leads">; canStart: boolean; reason?: string; activeRunId?: Id<"agentRuns">; activeRunStatus?: Doc<"agentRuns">["status"]; }; const runStatus = v.union( v.literal("pending"), v.literal("running"), v.literal("succeeded"), v.literal("failed"), v.literal("canceled"), ); const pageSpeedStrategy = v.union(v.literal("mobile"), v.literal("desktop")); const pageSpeedResultStatus = v.union( v.literal("succeeded"), v.literal("failed"), ); const pageSpeedErrorType = v.union( v.literal("quota"), v.literal("timeout"), v.literal("unavailable"), v.literal("invalid_url"), v.literal("api_error"), v.literal("unknown"), ); const requireOperator = async (ctx: MutationCtx | QueryCtx) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new Error("Nicht autorisiert."); } }; async function getActivePageSpeedAuditRun( ctx: MutationCtx | QueryCtx, leadId: Id<"leads">, ) { const existingPending = await ctx.db .query("agentRuns") .withIndex("by_type_and_status_and_leadId", (q) => q.eq("type", "audit").eq("status", "pending").eq("leadId", leadId), ) .take(1); if (existingPending[0]) { return existingPending[0]; } const existingRunning = await ctx.db .query("agentRuns") .withIndex("by_type_and_status_and_leadId", (q) => q.eq("type", "audit").eq("status", "running").eq("leadId", leadId), ) .take(1); return existingRunning[0] ?? null; } async function getActiveAuditGenerationRun( ctx: MutationCtx | QueryCtx, leadId: Id<"leads">, ) { const existingPending = await ctx.db .query("agentRuns") .withIndex("by_type_and_status_and_leadId", (q) => q .eq("type", "audit_generation") .eq("status", "pending") .eq("leadId", leadId), ) .take(1); if (existingPending[0]) { return existingPending[0]; } const existingRunning = await ctx.db .query("agentRuns") .withIndex("by_type_and_status_and_leadId", (q) => q .eq("type", "audit_generation") .eq("status", "running") .eq("leadId", leadId), ) .take(1); return existingRunning[0] ?? null; } async function getLeadAuditStartState( ctx: MutationCtx | QueryCtx, leadId: Id<"leads">, ): Promise { const lead = await ctx.db.get(leadId); if (!lead) { return { leadId, canStart: false, reason: "Lead nicht gefunden.", }; } if ( lead.priority === "blocked" || lead.priority === "defer" || lead.blacklistStatus === "blocked" || lead.contactStatus === "do_not_contact" ) { return { leadId, canStart: false, reason: "Lead ist gesperrt oder zurueckgestellt.", }; } if (!lead.websiteUrl) { return { leadId, canStart: false, reason: "Keine Website hinterlegt.", }; } const activeAuditRun = (await getActivePageSpeedAuditRun(ctx, leadId)) ?? (await getActiveAuditGenerationRun(ctx, leadId)); if (activeAuditRun) { return { leadId, canStart: false, reason: "Audit laeuft bereits.", activeRunId: activeAuditRun._id, activeRunStatus: activeAuditRun.status, }; } return { leadId, canStart: true, }; } async function queueLeadPageSpeedAuditForLead( ctx: MutationCtx, args: { leadId: Id<"leads">; parentRunId?: Id<"agentRuns">; triggeredBy: "internal" | "manual"; }, ): Promise | null> { const state = await getLeadAuditStartState(ctx, args.leadId); if (!state.canStart) { return state.activeRunId ?? null; } const now = Date.now(); const runId = await ctx.db.insert("agentRuns", { type: "audit", leadId: args.leadId, status: "pending", currentStep: "pagespeed_insights", counters: PAGE_SPEED_COUNTER_TEMPLATE, createdAt: now, updatedAt: now, }); await ctx.db.insert("agentRunEvents", { runId, level: "info", message: args.triggeredBy === "manual" ? "Audit-Start wurde manuell angefordert." : "PageSpeed-Analyse wurde in die Warteschlange gesetzt.", details: [ { label: "Lead", value: args.leadId }, ...(args.parentRunId ? [{ label: "Parent-Run", value: args.parentRunId }] : []), ], createdAt: now, }); await ctx.scheduler.runAfter( 0, internal.pageSpeedAction.processPageSpeedAudit, { runId, }, ); return runId; } export const getLeadAuditStartStates = query({ args: { leadIds: v.array(v.id("leads")), }, returns: v.array( v.object({ leadId: v.id("leads"), canStart: v.boolean(), reason: v.optional(v.string()), activeRunId: v.optional(v.id("agentRuns")), activeRunStatus: v.optional(runStatus), }), ), handler: async (ctx, args): Promise => { await requireOperator(ctx); const states: AuditStartState[] = []; for (const leadId of args.leadIds.slice(0, 120)) { states.push(await getLeadAuditStartState(ctx, leadId)); } return states; }, }); export const requestLeadAudit = mutation({ args: { leadId: v.id("leads"), }, returns: v.object({ runId: v.union(v.id("agentRuns"), v.null()), message: v.string(), }), handler: async (ctx, args): Promise<{ runId: Id<"agentRuns"> | null; message: string }> => { await requireOperator(ctx); const state = await getLeadAuditStartState(ctx, args.leadId); if (!state.canStart) { return { runId: state.activeRunId ?? null, message: state.reason ?? "Audit kann aktuell nicht gestartet werden.", }; } const runId = await queueLeadPageSpeedAuditForLead(ctx, { leadId: args.leadId, triggeredBy: "manual", }); return { runId, message: "Audit-Start wurde manuell angefordert.", }; }, }); export const queueLeadPageSpeedAudit = internalMutation({ args: { leadId: v.id("leads"), parentRunId: v.optional(v.id("agentRuns")), }, returns: v.union(v.id("agentRuns"), v.null()), handler: async (ctx, args): Promise | null> => { return await queueLeadPageSpeedAuditForLead(ctx, { leadId: args.leadId, parentRunId: args.parentRunId, triggeredBy: "internal", }); }, }); export const startPageSpeedAuditRun = internalMutation({ args: { runId: v.id("agentRuns"), }, returns: v.union( v.object({ lead: v.object({ _id: v.id("leads"), websiteUrl: v.string(), contactStatus: v.union( v.literal("new"), v.literal("missing_contact"), v.literal("audit_ready"), v.literal("outreach_ready"), v.literal("contacted"), v.literal("replied"), v.literal("do_not_contact"), ), }), auditId: v.optional(v.id("audits")), }), v.null(), ), handler: async (ctx, args): Promise< { lead: PageSpeedLead; auditId?: Id<"audits"> } | null > => { const now = Date.now(); const run = await ctx.db.get(args.runId); if (!run) { return null; } if (run.type !== "audit") { return null; } if (run.status !== "pending") { return null; } if (!run.leadId) { await ctx.db.patch(args.runId, { status: "failed", currentStep: "pagespeed_insights", errorSummary: "Run hat keine Lead-ID.", updatedAt: now, finishedAt: now, }); await ctx.db.insert("agentRunEvents", { runId: args.runId, level: "error", message: "PageSpeed-Analyse konnte nicht gestartet werden: Kein Lead verknüpft.", details: [{ label: "Lead-ID", value: "unbekannt" }], createdAt: now, }); return null; } const lead = await ctx.db.get(run.leadId); if (!lead) { await ctx.db.patch(args.runId, { status: "failed", currentStep: "pagespeed_insights", errorSummary: "Lead wurde nicht gefunden.", updatedAt: now, finishedAt: now, }); await ctx.db.insert("agentRunEvents", { runId: args.runId, level: "error", message: "PageSpeed-Analyse konnte nicht gestartet werden: Kein Lead mit Website-URL.", details: [{ label: "Lead-ID", value: run.leadId }], createdAt: now, }); return null; } if (!lead.websiteUrl) { await ctx.db.patch(args.runId, { status: "failed", currentStep: "pagespeed_insights", errorSummary: "Lead hat keine Website-URL.", updatedAt: now, finishedAt: now, }); await ctx.db.insert("agentRunEvents", { runId: args.runId, level: "error", message: "PageSpeed-Analyse konnte nicht gestartet werden: Kein Lead mit Website-URL.", details: [{ label: "Lead-ID", value: lead._id }], createdAt: now, }); return null; } await ctx.db.patch(args.runId, { status: "running", currentStep: "pagespeed_insights", startedAt: now, updatedAt: now, errorSummary: undefined, }); await ctx.db.insert("agentRunEvents", { runId: args.runId, level: "info", message: "PageSpeed-Analyse gestartet.", details: [{ label: "Lead-ID", value: lead._id }], createdAt: now, }); return { lead: { _id: lead._id, websiteUrl: lead.websiteUrl, contactStatus: lead.contactStatus, }, ...(run.auditId ? { auditId: run.auditId } : {}), }; }, }); export const persistPageSpeedResult = internalMutation({ args: { leadId: v.id("leads"), auditId: v.optional(v.id("audits")), runId: v.id("agentRuns"), strategy: pageSpeedStrategy, status: pageSpeedResultStatus, sourceUrl: v.string(), finalUrl: v.optional(v.string()), rawStorageId: v.optional(v.id("_storage")), errorType: v.optional(pageSpeedErrorType), errorSummary: v.optional(v.string()), fetchedAt: v.number(), normalized: v.optional( v.object({ scores: v.optional( v.object({ performance: v.optional(v.number()), accessibility: v.optional(v.number()), bestPractices: v.optional(v.number()), seo: v.optional(v.number()), }), ), metrics: v.optional( v.object({ firstContentfulPaintMs: v.optional(v.number()), largestContentfulPaintMs: v.optional(v.number()), cumulativeLayoutShift: v.optional(v.number()), totalBlockingTimeMs: v.optional(v.number()), speedIndexMs: v.optional(v.number()), }), ), opportunities: v.optional(v.array(v.string())), implications: v.optional(v.array(v.string())), }), ), }, returns: v.id("pageSpeedResults"), handler: async (ctx, args): Promise> => { return await ctx.db.insert("pageSpeedResults", { ...args, createdAt: Date.now(), }); }, }); export const finishPageSpeedAuditRun = internalMutation({ args: { runId: v.id("agentRuns"), status: runStatus, errorSummary: v.optional(v.string()), errors: v.optional(v.number()), }, handler: async (ctx, args) => { const now = Date.now(); await ctx.db.patch(args.runId, { status: args.status, updatedAt: now, finishedAt: now, currentStep: "pagespeed_insights", errorSummary: args.errorSummary, counters: { ...PAGE_SPEED_COUNTER_TEMPLATE, errors: args.errors ?? 0, }, }); }, });