Implement public audit pages
This commit is contained in:
236
convex/audits.ts
236
convex/audits.ts
@@ -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")),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user