import { v } from "convex/values"; import { normalizeListLimit } from "./domain"; import { internalMutation, mutation, query } from "./_generated/server"; import type { Doc, Id } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; export const AUDIT_REVIEW_NOTICE_AFTER_MS = 30 * 24 * 60 * 60 * 1000; const DETAIL_EVIDENCE_LIMIT = 50; const auditStatus = v.union( v.literal("draft"), v.literal("approved"), v.literal("published"), v.literal("deactivated"), ); const usedSkillsValidator = v.array( v.object({ id: v.optional(v.string()), name: v.string(), category: v.optional(v.string()), version: v.optional(v.string()), source: v.optional(v.string()), }), ); const skillSummaryValidator = v.array( v.object({ name: v.string(), purpose: v.string(), summary: v.string(), }), ); const publicObservationValidator = v.object({ title: v.string(), observation: v.string(), impact: v.string(), suggestion: v.string(), screenshotIds: v.optional(v.array(v.id("_storage"))), }); const publicOfferValidator = v.object({ body: v.string(), ctaLabel: v.optional(v.string()), ctaHref: v.optional(v.string()), }); type AuditDashboardRow = | { kind: "audit"; id: Id<"audits">; auditId: Id<"audits">; slug: string; title: string; checkedDomain: string; status: Doc<"audits">["status"]; pageCount: number; checkedPages: string[]; detailHref: string; createdAt: number; updatedAt: number; } | { kind: "generation"; id: Id<"agentRuns">; runId: Id<"agentRuns">; leadId: Id<"leads"> | null; title: string; checkedDomain: string; status: Doc<"agentRuns">["status"]; latestStage: string; stageStatus: Doc<"agentRuns">["status"]; errorSummary: string | null; pageCount: number; checkedPages: string[]; createdAt: number; updatedAt: number; }; const requireOperator = async (ctx: MutationCtx | QueryCtx) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new Error("Nicht autorisiert."); } }; const domainFromLead = ( lead: Pick, "companyName" | "websiteDomain" | "websiteUrl"> | null, ) => { if (lead?.websiteDomain) { return lead.websiteDomain; } if (lead?.websiteUrl) { try { return new URL(lead.websiteUrl).hostname; } catch { return lead.websiteUrl; } } return "unbekannte-domain"; }; const latestGenerationStage = (stages: Doc<"auditGenerations">[]) => { return [...stages].sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null; }; const normalizeComparableAuditUrl = (value: string | null | undefined) => { const trimmed = value?.trim(); if (!trimmed) { return ""; } const normalizeParsedUrl = (parsedUrl: URL) => { const hostname = parsedUrl.hostname.toLowerCase().replace(/^www\./, ""); const pathname = parsedUrl.pathname.replace(/\/+$/, ""); return `${hostname}${pathname}${parsedUrl.search}`.toLowerCase(); }; try { return normalizeParsedUrl(new URL(trimmed)); } catch { try { return normalizeParsedUrl(new URL(`https://${trimmed}`)); } catch { return trimmed .toLowerCase() .replace(/^https?:\/\//, "") .replace(/^www\./, "") .replace(/\/+$/, ""); } } }; const setIfPresent = ( target: Map, url: string | null | undefined, value: T, ) => { const key = normalizeComparableAuditUrl(url); if (key && !target.has(key)) { target.set(key, value); } }; const findByUrl = (source: Map, ...urls: Array) => { for (const url of urls) { const key = normalizeComparableAuditUrl(url); if (key && source.has(key)) { return source.get(key) ?? null; } } return null; }; const fallbackCheckedPageEvidence = (url: string) => ({ url, sourceUrl: null, finalUrl: null, pageKind: null, title: null, metaDescription: null, headings: [], visibleTextExcerpt: null, hasContactFormSignal: null, hasContactCtaSignal: null, usesHttps: null, missingMetaDescription: null, brokenInternalLinkCount: null, screenshots: [], createdAt: null, }); const toIsoDate = (timestamp: number | undefined, fallback: number) => { return new Date(timestamp ?? fallback).toISOString(); }; const fallbackObservation = (audit: { publicSummary?: string; publicBody?: string; textFindings?: string[]; }) => { const firstFinding = audit.textFindings?.find((finding) => finding.trim().length > 0); return { title: "Wichtigster Hebel", observation: firstFinding ?? audit.publicSummary ?? "Die Website hat bereits eine gute Grundlage.", impact: "Unklare Nutzerführung kann qualifizierte Anfragen kosten.", suggestion: audit.publicBody ?? "Priorisieren Sie die sichtbarsten Kontaktwege und testen Sie die wichtigsten Seiten mobil.", }; }; const publicContentForAudit = (audit: { checkedDomain: string; publicSummary?: string; publicBody?: string; publicObservations?: Array<{ title: string; observation: string; impact: string; suggestion: string; screenshotIds?: string[]; }>; publicOffer?: { body: string; ctaLabel?: string; ctaHref?: string; }; textFindings?: string[]; }) => { const observations = audit.publicObservations?.length ? audit.publicObservations : [fallbackObservation(audit)]; return { headline: audit.publicSummary ?? `Website-Audit fuer ${audit.checkedDomain}`, intro: audit.publicBody ?? "Diese Kurzfassung zeigt die wichtigsten oeffentlichen Findings aus dem geprueften Website-Audit.", observations, finalOffer: audit.publicOffer ?? { body: "Wenn Sie die naechsten Verbesserungen priorisieren moechten, besprechen wir die sinnvollsten Schritte gemeinsam.", ctaLabel: "Audit besprechen", }, }; }; const screenshotAlt = (viewport: "desktop" | "mobile", sourceUrl: string) => { return `${viewport === "desktop" ? "Desktop" : "Mobile"} Screenshot von ${sourceUrl}`; }; export const create = mutation({ args: { leadId: v.id("leads"), slug: v.string(), checkedDomain: v.string(), checkedPages: v.array(v.string()), usedSkills: v.optional(usedSkillsValidator), status: v.optional(auditStatus), internalSummary: v.optional(v.string()), publicSummary: v.optional(v.string()), publicBody: v.optional(v.string()), publicObservations: v.optional(v.array(publicObservationValidator)), publicOffer: v.optional(publicOfferValidator), ctaType: v.optional(v.string()), }, handler: async (ctx, args) => { await requireOperator(ctx); const now = Date.now(); const existing = await ctx.db .query("audits") .withIndex("by_slug", (q) => q.eq("slug", args.slug)) .take(1); if (existing.length > 0) { throw new Error("Audit slug already exists."); } return await ctx.db.insert("audits", { ...args, status: args.status ?? "draft", createdAt: now, updatedAt: now, }); }, }); export const getDetail = query({ args: { id: v.id("audits") }, handler: async (ctx, args) => { await requireOperator(ctx); const audit = await ctx.db.get(args.id); if (!audit) { return null; } const lead = await ctx.db.get(audit.leadId); const latestSuccessfulEnrichmentRun = await ctx.db .query("agentRuns") .withIndex("by_type_and_status_and_leadId", (q) => q .eq("type", "website_enrichment") .eq("status", "succeeded") .eq("leadId", audit.leadId), ) .order("desc") .take(1); const enrichmentRunId = latestSuccessfulEnrichmentRun[0]?._id ?? null; const crawlPages = enrichmentRunId ? await ctx.db .query("websiteCrawlPages") .withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId)) .order("desc") .take(DETAIL_EVIDENCE_LIMIT) : []; const technicalChecks = enrichmentRunId ? await ctx.db .query("websiteTechnicalChecks") .withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId)) .order("desc") .take(DETAIL_EVIDENCE_LIMIT) : []; const crawlScreenshots = enrichmentRunId ? await ctx.db .query("websiteCrawlScreenshots") .withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId)) .order("desc") .take(DETAIL_EVIDENCE_LIMIT) : []; const pagesByUrl = new Map>(); for (const page of crawlPages) { setIfPresent(pagesByUrl, page.sourceUrl, page); setIfPresent(pagesByUrl, page.finalUrl, page); } const checksByUrl = new Map>(); for (const checks of technicalChecks) { setIfPresent(checksByUrl, checks.sourceUrl, checks); setIfPresent(checksByUrl, checks.finalUrl, checks); } const screenshotsByUrl = new Map< string, Array<{ id: Id<"_storage">; url: string; viewport: Doc<"websiteCrawlScreenshots">["viewport"]; sourceUrl: string; width: number; height: number; createdAt: number; }> >(); for (const screenshot of crawlScreenshots) { const url = await ctx.storage.getUrl(screenshot.storageId); if (!url) { continue; } const key = normalizeComparableAuditUrl(screenshot.sourceUrl); if (!key) { continue; } const current = screenshotsByUrl.get(key) ?? []; current.push({ id: screenshot.storageId, url, viewport: screenshot.viewport, sourceUrl: screenshot.sourceUrl, width: screenshot.width, height: screenshot.height, createdAt: screenshot.createdAt, }); screenshotsByUrl.set(key, current); } const checkedPages = audit.checkedPages.map((checkedUrl) => { const page = findByUrl(pagesByUrl, checkedUrl); if (!page) { return fallbackCheckedPageEvidence(checkedUrl); } const checks = findByUrl(checksByUrl, checkedUrl, page.sourceUrl, page.finalUrl); const screenshots = [ ...( findByUrl(screenshotsByUrl, checkedUrl, page.sourceUrl, page.finalUrl) ?? [] ), ].sort((a, b) => b.createdAt - a.createdAt); return { url: checkedUrl, sourceUrl: page.sourceUrl, finalUrl: page.finalUrl, pageKind: page.pageKind, title: page.title ?? null, metaDescription: page.metaDescription ?? null, headings: page.headings.slice(0, DETAIL_EVIDENCE_LIMIT), visibleTextExcerpt: page.visibleTextExcerpt ?? null, hasContactFormSignal: page.hasContactFormSignal, hasContactCtaSignal: page.hasContactCtaSignal, usesHttps: checks?.usesHttps ?? null, missingMetaDescription: checks?.missingMetaDescription ?? null, brokenInternalLinkCount: checks?.brokenInternalLinkCount ?? null, screenshots, createdAt: page.createdAt, }; }); return { audit, lead, sourceSummaries: { checkedPages, }, }; }, }); export const get = query({ args: { id: v.id("audits") }, handler: async (ctx, args) => { await requireOperator(ctx); return await ctx.db.get(args.id); }, }); export const upsertFromAuditGeneration = internalMutation({ args: { leadId: v.id("leads"), runId: v.id("agentRuns"), auditId: v.optional(v.id("audits")), checkedDomain: v.string(), checkedPages: v.array(v.string()), internalSummary: v.optional(v.string()), multimodalSummary: v.optional(v.string()), publicSummary: v.optional(v.string()), publicBody: v.optional(v.string()), publicObservations: v.optional(v.array(publicObservationValidator)), publicOffer: v.optional(publicOfferValidator), usedSkills: v.optional(usedSkillsValidator), skillSummaries: v.optional(skillSummaryValidator), }, handler: async (ctx, args) => { const now = Date.now(); const lead = await ctx.db.get(args.leadId); if (!lead) { throw new Error("Lead wurde nicht gefunden."); } if (args.auditId) { const existing = await ctx.db.get(args.auditId); if (!existing) { throw new Error("Audit wurde nicht gefunden."); } await ctx.db.patch(args.auditId, { checkedDomain: args.checkedDomain, checkedPages: args.checkedPages, internalSummary: args.internalSummary, multimodalSummary: args.multimodalSummary, publicSummary: args.publicSummary, publicBody: args.publicBody, publicObservations: args.publicObservations, publicOffer: args.publicOffer, usedSkills: args.usedSkills, skillSummaries: args.skillSummaries, updatedAt: now, }); return args.auditId; } const safeCheckedDomain = args.checkedDomain.trim().toLowerCase(); const domainTag = safeCheckedDomain.length > 0 ? safeCheckedDomain.replace(/[^a-z0-9]+/g, "-").slice(0, 50) : "lead"; let slug = `audit-${domainTag}-${args.leadId}-${now}`; const slugCandidates = await ctx.db .query("audits") .withIndex("by_slug", (q) => q.eq("slug", slug)) .take(1); if (slugCandidates.length > 0) { slug = `${slug}-${Math.floor(now / 1_000)}`; } return await ctx.db.insert("audits", { leadId: args.leadId, status: "draft", slug, checkedDomain: args.checkedDomain, checkedPages: args.checkedPages, internalSummary: args.internalSummary, multimodalSummary: args.multimodalSummary, publicSummary: args.publicSummary, publicBody: args.publicBody, publicObservations: args.publicObservations, publicOffer: args.publicOffer, usedSkills: args.usedSkills, skillSummaries: args.skillSummaries, createdAt: now, updatedAt: now, }); }, }); export const getBySlug = query({ args: { slug: v.string() }, handler: async (ctx, args) => { await requireOperator(ctx); const audits = await ctx.db .query("audits") .withIndex("by_slug", (q) => q.eq("slug", args.slug)) .take(1); return audits[0] ?? null; }, }); export const getPublicBySlug = query({ args: { slug: v.string() }, handler: async (ctx: QueryCtx, args) => { const audit = await ctx.db .query("audits") .withIndex("by_slug", (q) => q.eq("slug", args.slug)) .unique(); if (!audit) { return null; } if (audit.status === "deactivated") { return { publicationStatus: "deactivated" as const }; } if (audit.status !== "published") { return { publicationStatus: "draft" as const }; } const lead = await ctx.db.get(audit.leadId); const screenshots = await ctx.db .query("auditScreenshots") .withIndex("by_auditId", (q) => q.eq("auditId", audit._id)) .order("desc") .take(8); const publicScreenshots = []; for (const screenshot of screenshots) { const url = await ctx.storage.getUrl(screenshot.storageId); if (!url) { continue; } publicScreenshots.push({ id: screenshot.storageId, url, alt: screenshotAlt(screenshot.viewport, screenshot.sourceUrl), viewport: screenshot.viewport, sourceUrl: screenshot.sourceUrl, width: screenshot.width, height: screenshot.height, }); } return { publicationStatus: "published" as const, companyName: lead?.companyName ?? audit.checkedDomain, domain: audit.checkedDomain, publishedAt: toIsoDate(audit.publishedAt, audit.updatedAt), publicContent: publicContentForAudit(audit), screenshots: publicScreenshots, }; }, }); export const savePublicAuditContent = mutation({ args: { id: v.id("audits"), publicSummary: v.optional(v.string()), publicBody: v.optional(v.string()), publicObservations: v.optional(v.array(publicObservationValidator)), publicOffer: v.optional(publicOfferValidator), }, handler: async (ctx, args) => { await requireOperator(ctx); const audit = await ctx.db.get(args.id); if (!audit) { throw new Error("Audit wurde nicht gefunden."); } const now = Date.now(); await ctx.db.patch(args.id, { publicSummary: args.publicSummary, publicBody: args.publicBody, publicObservations: args.publicObservations, publicOffer: args.publicOffer, status: audit.status === "published" ? "approved" : audit.status, updatedAt: now, }); return args.id; }, }); export const publishPublicAudit = mutation({ args: { id: v.id("audits") }, handler: async (ctx, args) => { await requireOperator(ctx); const audit = await ctx.db.get(args.id); if (!audit) { throw new Error("Audit wurde nicht gefunden."); } const now = Date.now(); await ctx.db.patch(args.id, { status: "published", publishedAt: now, reviewDueAt: now + AUDIT_REVIEW_NOTICE_AFTER_MS, lifecycleNotificationAt: undefined, lifecycleExtendedUntil: undefined, deactivatedAt: undefined, updatedAt: now, }); return { slug: audit.slug }; }, }); export const reapprovePublicAudit = mutation({ args: { id: v.id("audits") }, handler: async (ctx, args) => { await requireOperator(ctx); const audit = await ctx.db.get(args.id); if (!audit) { throw new Error("Audit wurde nicht gefunden."); } const now = Date.now(); await ctx.db.patch(args.id, { status: "published", publishedAt: now, reviewDueAt: now + AUDIT_REVIEW_NOTICE_AFTER_MS, lifecycleNotificationAt: undefined, lifecycleExtendedUntil: undefined, deactivatedAt: undefined, updatedAt: now, }); return { slug: audit.slug }; }, }); export const extendPublicAuditLifecycle = mutation({ args: { id: v.id("audits"), days: v.number(), }, handler: async (ctx, args) => { await requireOperator(ctx); const audit = await ctx.db.get(args.id); if (!audit) { throw new Error("Audit wurde nicht gefunden."); } const now = Date.now(); await ctx.db.patch(args.id, { status: "published", lifecycleExtendedUntil: now + args.days * 24 * 60 * 60 * 1000, reviewDueAt: now + args.days * 24 * 60 * 60 * 1000, deactivatedAt: undefined, updatedAt: now, }); return { slug: audit.slug }; }, }); export const deactivatePublicAudit = mutation({ args: { id: v.id("audits") }, handler: async (ctx, args) => { await requireOperator(ctx); const audit = await ctx.db.get(args.id); if (!audit) { throw new Error("Audit wurde nicht gefunden."); } const now = Date.now(); await ctx.db.patch(args.id, { status: "deactivated", deactivatedAt: now, updatedAt: now, }); return { slug: audit.slug }; }, }); export const list = query({ args: { leadId: v.optional(v.id("leads")), status: v.optional(auditStatus), limit: v.optional(v.number()), }, handler: async (ctx, args) => { await requireOperator(ctx); const limit = normalizeListLimit(args.limit); if (args.leadId) { const leadId = args.leadId; return await ctx.db .query("audits") .withIndex("by_leadId", (q) => q.eq("leadId", leadId)) .order("desc") .take(limit); } if (args.status) { const status = args.status; return await ctx.db .query("audits") .withIndex("by_status", (q) => q.eq("status", status)) .order("desc") .take(limit); } return await ctx.db.query("audits").order("desc").take(limit); }, }); export const listDashboardRows = query({ args: { limit: v.optional(v.number()), }, handler: async (ctx: QueryCtx, args): Promise => { await requireOperator(ctx); const limit = normalizeListLimit(args.limit); const audits = await ctx.db.query("audits").order("desc").take(limit); const finalAuditLeadIds = new Set(); const finalAuditRunIds = new Set(); const finalAuditIds = new Set(); const rows: AuditDashboardRow[] = audits.map((audit) => { finalAuditLeadIds.add(audit.leadId); finalAuditIds.add(audit._id); return { kind: "audit", id: audit._id, auditId: audit._id, slug: audit.slug, title: audit.slug, checkedDomain: audit.checkedDomain, status: audit.status, pageCount: audit.checkedPages.length, checkedPages: audit.checkedPages, detailHref: `/dashboard/audits/${audit._id}`, createdAt: audit.createdAt, updatedAt: audit.updatedAt, }; }); for (const audit of audits) { const linkedGenerations = await ctx.db .query("auditGenerations") .withIndex("by_auditId", (q) => q.eq("auditId", audit._id)) .take(20); for (const generation of linkedGenerations) { finalAuditRunIds.add(generation.runId); } } const generationRuns = await ctx.db .query("agentRuns") .withIndex("by_type", (q) => q.eq("type", "audit_generation")) .order("desc") .take(limit); for (const run of generationRuns) { if (!run.leadId) { continue; } const directFinalAudit = run.auditId ? await ctx.db.get(run.auditId) : null; const leadFinalAudits = await ctx.db .query("audits") .withIndex("by_leadId", (q) => q.eq("leadId", run.leadId as Id<"leads">)) .take(1); if ( finalAuditRunIds.has(run._id) || (run.auditId && finalAuditIds.has(run.auditId)) || directFinalAudit || finalAuditLeadIds.has(run.leadId) || leadFinalAudits.length > 0 ) { continue; } const stages = await ctx.db .query("auditGenerations") .withIndex("by_runId", (q) => q.eq("runId", run._id)) .order("desc") .take(20); const latestStage = latestGenerationStage(stages); const lead = await ctx.db.get(run.leadId); const checkedDomain = domainFromLead(lead); rows.push({ kind: "generation", id: run._id, runId: run._id, leadId: run.leadId, title: lead?.companyName ?? checkedDomain, checkedDomain, status: run.status, latestStage: latestStage?.stage ?? run.currentStep ?? "audit_generation", stageStatus: latestStage?.status ?? run.status, errorSummary: run.errorSummary ?? latestStage?.errorSummary ?? null, pageCount: 0, checkedPages: [], createdAt: run.createdAt, updatedAt: Math.max(run.updatedAt, latestStage?.updatedAt ?? 0), }); } return rows.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit); }, });