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 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 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); return { audit, lead }; }, }); 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); }, });