Implement public audit pages

This commit is contained in:
Matthias
2026-06-05 14:14:07 +02:00
parent 03cb65fde4
commit 47ee2c2d51
25 changed files with 1039 additions and 45 deletions

View File

@@ -2,6 +2,7 @@ import { v } from "convex/values";
import { normalizeListLimit } from "./domain";
import { internalMutation, mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
const auditStatus = v.union(
v.literal("draft"),
@@ -24,6 +25,86 @@ const skillSummaryValidator = v.array(
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: {
@@ -36,6 +117,8 @@ export const create = mutation({
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) => {
@@ -89,6 +172,8 @@ export const upsertFromAuditGeneration = internalMutation({
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),
},
@@ -112,6 +197,8 @@ export const upsertFromAuditGeneration = internalMutation({
multimodalSummary: args.multimodalSummary,
publicSummary: args.publicSummary,
publicBody: args.publicBody,
publicObservations: args.publicObservations,
publicOffer: args.publicOffer,
usedSkills: args.usedSkills,
skillSummaries: args.skillSummaries,
updatedAt: now,
@@ -145,6 +232,8 @@ export const upsertFromAuditGeneration = internalMutation({
multimodalSummary: args.multimodalSummary,
publicSummary: args.publicSummary,
publicBody: args.publicBody,
publicObservations: args.publicObservations,
publicOffer: args.publicOffer,
usedSkills: args.usedSkills,
skillSummaries: args.skillSummaries,
createdAt: now,
@@ -165,6 +254,153 @@ export const getBySlug = query({
},
});
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,
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,
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")),

View File

@@ -3,6 +3,7 @@
import { api, internal } from "./_generated/api";
import { internalAction } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import type { ActionCtx } from "./_generated/server";
import { v } from "convex/values";
import {
classifyPageSpeedError,
@@ -101,6 +102,44 @@ function classifyPageSpeedFailure(input: unknown, apiKey?: string | null) {
};
}
type StartedPageSpeedAudit = {
lead: {
_id: Id<"leads">;
websiteUrl: string;
};
auditId?: Id<"audits">;
};
async function queueAuditGenerationAfterPageSpeed(
ctx: ActionCtx,
runId: Id<"agentRuns">,
started: StartedPageSpeedAudit,
) {
try {
await ctx.runMutation(internal.auditGeneration.queueLeadAuditGeneration, {
leadId: started.lead._id,
...(started.auditId ? { auditId: started.auditId } : {}),
parentRunId: runId,
});
} catch (auditQueueError) {
await ctx.runMutation(api.runs.appendEvent, {
runId,
level: "warning",
message: "Audit-Generierung konnte nicht in die Warteschlange gesetzt werden.",
details: [
{ label: "Lead", value: started.lead._id },
{
label: "Fehler",
value: auditQueueError instanceof Error
? auditQueueError.message
: String(auditQueueError),
source: "audit_generation_queue",
},
],
});
}
}
export const processPageSpeedAudit = internalAction({
args: {
runId: v.id("agentRuns"),
@@ -109,15 +148,7 @@ export const processPageSpeedAudit = internalAction({
const apiKeyRaw = process.env.PAGESPEED_API_KEY?.trim();
const apiKey = apiKeyRaw ? apiKeyRaw : undefined;
let started:
| {
lead: {
_id: Id<"leads">;
websiteUrl: string;
};
auditId?: Id<"audits">;
}
| null = null;
let started: StartedPageSpeedAudit | null = null;
try {
started = await ctx.runMutation(internal.pageSpeed.startPageSpeedAuditRun, {
@@ -267,6 +298,8 @@ export const processPageSpeedAudit = internalAction({
: undefined,
});
await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started);
return args.runId;
} catch (error) {
const { errorSummary } = classifyPageSpeedFailure(error, apiKeyRaw);
@@ -283,6 +316,7 @@ export const processPageSpeedAudit = internalAction({
message: "PageSpeed-Analyse fehlgeschlagen.",
details: [{ label: "Fehler", value: errorSummary, source: "pagespeed_action" }],
});
await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started);
return null;
}
},

View File

@@ -156,6 +156,18 @@ const playwrightSummary = v.object({
formsFound: v.number(),
notes: v.optional(v.array(v.string())),
});
const publicAuditObservation = v.object({
title: v.string(),
observation: v.string(),
impact: v.string(),
suggestion: v.string(),
screenshotIds: v.optional(v.array(v.id("_storage"))),
});
const publicAuditOffer = v.object({
body: v.string(),
ctaLabel: v.optional(v.string()),
ctaHref: v.optional(v.string()),
});
const eventDetail = v.object({
label: v.string(),
value: v.string(),
@@ -285,6 +297,8 @@ export default defineSchema({
internalSummary: v.optional(v.string()),
publicSummary: v.optional(v.string()),
publicBody: v.optional(v.string()),
publicObservations: v.optional(v.array(publicAuditObservation)),
publicOffer: v.optional(publicAuditOffer),
ctaType: v.optional(v.string()),
publishedAt: v.optional(v.number()),
reviewDueAt: v.optional(v.number()),