import { v } from "convex/values"; import { normalizeListLimit } from "./domain"; import { internalMutation, mutation, query } from "./_generated/server"; 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({ name: v.string(), category: 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()), }); const requireOperator = async (ctx: MutationCtx) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new Error("Nicht autorisiert."); } }; 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) => { 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) => { 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) => { 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) => { 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) => { 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); }, });