Compare commits

...

2 Commits

Author SHA1 Message Date
Matthias
1feccb9bdf Merge public audit pages 2026-06-05 14:14:17 +02:00
Matthias
47ee2c2d51 Implement public audit pages 2026-06-05 14:14:07 +02:00
25 changed files with 1039 additions and 45 deletions

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { revalidatePublicAudit } from "@/lib/audits/public-audit-revalidation";
import { parsePublicAuditSlug } from "@/lib/audits/slugs";
export async function POST(request: Request) {
const secret = process.env.PUBLIC_AUDIT_REVALIDATION_SECRET;
const authorization = request.headers.get("authorization");
if (!secret || authorization !== `Bearer ${secret}`) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const body = (await request.json().catch(() => null)) as { slug?: unknown } | null;
const normalizedSlug =
typeof body?.slug === "string" ? parsePublicAuditSlug(body.slug) : null;
if (!normalizedSlug) {
return NextResponse.json({ ok: false, error: "Invalid slug" }, { status: 400 });
}
revalidatePublicAudit(normalizedSlug);
return NextResponse.json({ ok: true });
}

View File

@@ -1,27 +1,66 @@
import { FileText } from "lucide-react"; import type { Metadata } from "next";
import { Suspense } from "react";
import { cacheLife, cacheTag } from "next/cache";
import { fetchQuery } from "convex/nextjs";
export default async function PublicAuditPage({ import { PublicAuditPage } from "@/components/public-audit/public-audit-page";
params, import { PublicAuditStatus } from "@/components/public-audit/public-audit-status";
}: { import { api } from "@/convex/_generated/api";
import { publicAuditCacheTag } from "@/lib/audits/public-audit-cache";
import { toPublicAuditRenderState } from "@/lib/audits/public-audit-presenter";
import type { PublicAuditLookupResult } from "@/lib/audits/public-audit-types";
import { parsePublicAuditSlug } from "@/lib/audits/slugs";
export const metadata: Metadata = {
title: "Website-Audit",
robots: {
index: false,
follow: false,
googleBot: {
index: false,
follow: false,
},
},
};
type PublicAuditRouteProps = {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}) { };
const { slug } = await params;
async function getCachedPublicAudit(slug: string): Promise<PublicAuditLookupResult> {
"use cache";
const normalizedSlug = parsePublicAuditSlug(slug);
if (!normalizedSlug) {
return null;
}
cacheTag(publicAuditCacheTag(normalizedSlug));
cacheLife("days");
return await fetchQuery(api.audits.getPublicBySlug, { slug: normalizedSlug });
}
async function PublicAuditContent({ params }: PublicAuditRouteProps) {
const { slug } = await params;
const result = await getCachedPublicAudit(slug);
const renderState = toPublicAuditRenderState(result);
if (renderState.kind === "pending") {
return <PublicAuditStatus status="pending" />;
}
if (renderState.kind === "unavailable") {
return <PublicAuditStatus status="unavailable" />;
}
return <PublicAuditPage audit={renderState.audit} />;
}
export default function PublicAuditRoute({ params }: PublicAuditRouteProps) {
return ( return (
<main className="flex min-h-dvh items-center justify-center bg-background px-6 py-12"> <Suspense fallback={<PublicAuditStatus status="pending" />}>
<section className="w-full max-w-2xl rounded-lg border bg-card p-6 text-card-foreground"> <PublicAuditContent params={params} />
<FileText className="mb-5 size-6 text-muted-foreground" /> </Suspense>
<p className="text-sm font-medium text-muted-foreground">
Audit: {slug}
</p>
<h1 className="mt-3 text-3xl font-semibold tracking-normal">
Dieser Audit ist noch nicht freigegeben
</h1>
<p className="mt-4 text-sm leading-6 text-muted-foreground">
Sobald der Bericht manuell geprueft und veroeffentlicht wurde,
erscheinen hier die freigegebenen Beobachtungen und Empfehlungen.
</p>
</section>
</main>
); );
} }

7
app/audit/layout.tsx Normal file
View File

@@ -0,0 +1,7 @@
export default function AuditLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return <div className="bg-slate-50 text-slate-950">{children}</div>;
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { Suspense } from "react";
import { ConvexClientProvider } from "@/components/convex-client-provider"; import { ConvexClientProvider } from "@/components/convex-client-provider";
import { getToken } from "@/lib/auth-server"; import { getToken } from "@/lib/auth-server";
import "./globals.css"; import "./globals.css";
@@ -19,20 +20,30 @@ export const metadata: Metadata = {
description: "Interner Akquise-Agent fuer lokale Webdesign-Leads", description: "Interner Akquise-Agent fuer lokale Webdesign-Leads",
}; };
export default async function RootLayout({ async function AuthenticatedConvexProvider({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const token = await getToken(); const token = await getToken();
return <ConvexClientProvider initialToken={token}>{children}</ConvexClientProvider>;
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return ( return (
<html <html
lang="de" lang="de"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<ConvexClientProvider initialToken={token}>{children}</ConvexClientProvider> <Suspense fallback={null}>
<AuthenticatedConvexProvider>{children}</AuthenticatedConvexProvider>
</Suspense>
</body> </body>
</html> </html>
); );

14
app/sitemap.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { MetadataRoute } from "next";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: siteUrl,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
];
}

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-12 id: TASK-12
title: Publish customer audit pages with manual approval title: Publish customer audit pages with manual approval
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-06-03 19:14' created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 12:13'
labels: labels:
- mvp - mvp
- audit - audit
@@ -24,11 +25,11 @@ Build the public customer-facing audit page system under the audit domain. Pages
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Public audit pages render approved audit content with company name, domain, screenshots, observations, impact, suggestions, and final offer/CTA - [x] #1 Public audit pages render approved audit content with company name, domain, screenshots, observations, impact, suggestions, and final offer/CTA
- [ ] #2 Unapproved audit URLs show Dieser Audit ist noch nicht freigegeben without leaking company details - [x] #2 Unapproved audit URLs show Dieser Audit ist noch nicht freigegeben without leaking company details
- [ ] #3 Deactivated audit URLs show a neutral unavailable message without exposing audit content - [x] #3 Deactivated audit URLs show a neutral unavailable message without exposing audit content
- [ ] #4 Audit pages are noindex, excluded from sitemap/public listing, and use a calm fixed light design - [x] #4 Audit pages are noindex, excluded from sitemap/public listing, and use a calm fixed light design
- [ ] #5 Approved pages are cached and cache is invalidated when the audit is edited and re-approved - [x] #5 Approved pages are cached and cache is invalidated when the audit is edited and re-approved
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -40,3 +41,17 @@ Build the public customer-facing audit page system under the audit domain. Pages
4. Add noindex metadata and ensure audit routes are not listed in sitemap/navigation. 4. Add noindex metadata and ensure audit routes are not listed in sitemap/navigation.
5. Add cache/revalidation behavior tied to approval and update actions. 5. Add cache/revalidation behavior tied to approval and update actions.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Reapplying TASK-12 changes after failed pull lost previous implementation. Upstream TASK-1 through TASK-11 code is now present locally; implementation will adapt to current Convex/generated API and existing app structure.
Reapplied TASK-12 public audit implementation after pull-loss recovery. Verified with pnpm test (244/244), pnpm exec tsc --noEmit, pnpm lint (0 errors, 2 existing generated warnings), and pnpm build using the updated .env.local.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Public audit pages were reapplied and verified: approved public pages render public audit content with screenshots, observations, suggestions and CTA; hidden/deactivated states do not leak details; pages are noindex and excluded from sitemap; cache/revalidation hooks are in place. Verified with pnpm test, tsc, lint, and build.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-13 id: TASK-13
title: Build the audit and outreach review workspace title: Build the audit and outreach review workspace
status: To Do status: In Progress
assignee: [] assignee: []
created_date: '2026-06-03 19:14' created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 12:13'
labels: labels:
- mvp - mvp
- review - review
@@ -35,9 +36,16 @@ Create the internal review workspace where Matthias can inspect and edit the fin
## Implementation Plan ## Implementation Plan
<!-- SECTION:PLAN:BEGIN --> <!-- SECTION:PLAN:BEGIN -->
1. Build review route/detail UI with tabs for Audit, E-Mail, Telefon, Quellen, Rohdaten, and Skills. 1. Wire PageSpeed completion into audit_generation queue
2. Add edit forms for audit text, email subject/body, phone script, and follow-up. 2. Verify handoff with regression tests
3. Add approval actions for audit publication and separate email sending readiness. 3. Build review workspace UI and edit/approval flows
4. Show source/contact confidence without exposing unnecessary raw noise by default. 4. Verify state transitions back into dashboard/funnel
5. Verify state transitions back into the Kanban/Funnel.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Starting TASK-13 with the missing PageSpeed-to-audit-generation handoff so generated audit content exists for the review workspace.
Implemented first TASK-13 prerequisite: PageSpeed completion now queues audit_generation for the same lead via internal.auditGeneration.queueLeadAuditGeneration. Queue failures are logged as warnings and do not fail the PageSpeed run. Verified with pnpm test (245/245), pnpm exec tsc --noEmit, pnpm lint (0 errors, existing generated warnings), and pnpm build using .env.local.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,32 @@
---
id: TASK-27
title: Trigger audit generation after PageSpeed audit
status: To Do
assignee: []
created_date: '2026-06-05 12:10'
updated_date: '2026-06-05 12:12'
labels: []
dependencies: []
priority: high
ordinal: 29000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Wire the existing AI audit generation queue into the current automated flow so completed PageSpeed audit runs schedule audit_generation for the same lead.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Successful PageSpeed audit runs queue audit generation for the lead
- [ ] #2 Failed PageSpeed audit runs still queue audit generation when a lead was started so partial evidence can produce an audit
- [ ] #3 Existing dedupe in queueLeadAuditGeneration prevents duplicate audit_generation runs
- [ ] #4 Regression tests cover the PageSpeed-to-audit-generation handoff
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Created accidentally while implementing the PageSpeed-to-audit-generation handoff. Superseded by TASK-13 because the handoff is a prerequisite for the audit/outreach review workspace. Do not implement separately.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,124 @@
import { ArrowRight, CheckCircle2, ExternalLink } from "lucide-react";
import type { PublicAuditRenderState } from "@/lib/audits/public-audit-types";
import { PublicAuditScreenshot } from "./public-audit-screenshot";
type PublicAuditPageProps = {
audit: Extract<PublicAuditRenderState, { kind: "published" }>["audit"];
};
export function PublicAuditPage({ audit }: PublicAuditPageProps) {
return (
<main className="min-h-dvh bg-slate-50 text-slate-950">
<section className="border-b border-slate-200 bg-white">
<div className="mx-auto grid min-h-[72dvh] w-full max-w-6xl content-center gap-10 px-6 py-14 md:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)] md:px-8">
<div className="max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-normal text-emerald-700">
Öffentliche Audit-Kurzfassung
</p>
<h1 className="mt-4 text-4xl font-semibold tracking-normal text-slate-950 md:text-5xl">
{audit.headline}
</h1>
<p className="mt-5 text-lg leading-8 text-slate-700">{audit.intro}</p>
<dl className="mt-8 grid gap-4 text-sm text-slate-700 sm:grid-cols-2">
<div>
<dt className="font-semibold text-slate-950">Unternehmen</dt>
<dd className="mt-1">{audit.companyName}</dd>
</div>
<div>
<dt className="font-semibold text-slate-950">Geprüfte Domain</dt>
<dd className="mt-1">{audit.domain}</dd>
</div>
</dl>
</div>
<aside className="self-end border-l-4 border-emerald-600 bg-emerald-50 px-5 py-4 text-sm leading-6 text-emerald-950">
Diese Fassung enthält nur freigegebene Beobachtungen und keine internen Scores,
Skills oder Rohdaten.
</aside>
</div>
</section>
<section className="mx-auto w-full max-w-6xl px-6 py-12 md:px-8">
<div className="grid gap-5">
{audit.observations.map((observation, index) => (
<article
key={`${observation.title}-${index}`}
className="rounded-lg border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-1 h-5 w-5 shrink-0 text-emerald-700" aria-hidden />
<div>
<h2 className="text-xl font-semibold tracking-normal text-slate-950">
{observation.title}
</h2>
<div className="mt-5 grid gap-5 md:grid-cols-3">
<div>
<h3 className="text-sm font-semibold text-slate-950">Beobachtung</h3>
<p className="mt-2 text-sm leading-6 text-slate-700">
{observation.observation}
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-slate-950">Auswirkung</h3>
<p className="mt-2 text-sm leading-6 text-slate-700">
{observation.impact}
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-slate-950">Vorschlag</h3>
<p className="mt-2 text-sm leading-6 text-slate-700">
{observation.suggestion}
</p>
</div>
</div>
</div>
</div>
</article>
))}
</div>
</section>
{audit.screenshots.length > 0 ? (
<section className="border-y border-slate-200 bg-white">
<div className="mx-auto w-full max-w-6xl px-6 py-12 md:px-8">
<h2 className="text-2xl font-semibold tracking-normal text-slate-950">
Screenshots aus der Prüfung
</h2>
<div className="mt-6 grid gap-5 md:grid-cols-2">
{audit.screenshots.map((screenshot) => (
<PublicAuditScreenshot key={screenshot.id} screenshot={screenshot} />
))}
</div>
</div>
</section>
) : null}
<section className="mx-auto w-full max-w-6xl px-6 py-12 md:px-8">
<div className="rounded-lg border border-slate-200 bg-white p-6 shadow-sm md:flex md:items-center md:justify-between md:gap-8">
<div>
<h2 className="text-2xl font-semibold tracking-normal text-slate-950">
Nächster sinnvoller Schritt
</h2>
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-700">
{audit.finalOffer.body}
</p>
</div>
{audit.finalOffer.ctaHref ? (
<a
href={audit.finalOffer.ctaHref}
className="mt-6 inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-slate-950 px-4 text-sm font-semibold text-white transition hover:bg-slate-800 md:mt-0"
>
{audit.finalOffer.ctaLabel ?? "Audit besprechen"}
{audit.finalOffer.ctaHref.startsWith("/") ? (
<ArrowRight className="h-4 w-4" aria-hidden />
) : (
<ExternalLink className="h-4 w-4" aria-hidden />
)}
</a>
) : null}
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,26 @@
import type { PublicAuditScreenshot as PublicAuditScreenshotData } from "@/lib/audits/public-audit-types";
type PublicAuditScreenshotProps = {
screenshot: PublicAuditScreenshotData;
};
export function PublicAuditScreenshot({ screenshot }: PublicAuditScreenshotProps) {
return (
<figure className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={screenshot.url}
alt={screenshot.alt}
width={screenshot.width}
height={screenshot.height}
className="aspect-[16/10] w-full object-cover"
/>
<figcaption className="flex items-center justify-between gap-3 border-t border-slate-200 px-4 py-3 text-xs text-slate-600">
<span className="font-medium uppercase tracking-normal text-slate-700">
{screenshot.viewport === "desktop" ? "Desktop" : "Mobil"}
</span>
<span className="truncate">{screenshot.sourceUrl}</span>
</figcaption>
</figure>
);
}

View File

@@ -0,0 +1,27 @@
type PublicAuditStatusProps = {
status?: "pending" | "unavailable";
};
export function PublicAuditStatus({ status = "pending" }: PublicAuditStatusProps) {
const isUnavailable = status === "unavailable";
return (
<main className="flex min-h-dvh items-center justify-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="w-full max-w-xl rounded-lg border border-slate-200 bg-white p-8 shadow-sm">
<p className="text-sm font-semibold uppercase tracking-normal text-slate-500">
Website-Audit
</p>
<h1 className="mt-3 text-2xl font-semibold tracking-normal text-slate-950">
{isUnavailable
? "Dieser Audit ist nicht verfügbar"
: "Dieser Audit ist noch nicht freigegeben"}
</h1>
<p className="mt-4 text-sm leading-6 text-slate-600">
{isUnavailable
? "Die angeforderte öffentliche Kurzfassung wurde entfernt oder existiert nicht."
: "Die öffentliche Kurzfassung wird erst nach manueller Prüfung angezeigt."}
</p>
</section>
</main>
);
}

View File

@@ -2,6 +2,7 @@ import { v } from "convex/values";
import { normalizeListLimit } from "./domain"; import { normalizeListLimit } from "./domain";
import { internalMutation, mutation, query } from "./_generated/server"; import { internalMutation, mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
const auditStatus = v.union( const auditStatus = v.union(
v.literal("draft"), v.literal("draft"),
@@ -24,6 +25,86 @@ const skillSummaryValidator = v.array(
summary: 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({ export const create = mutation({
args: { args: {
@@ -36,6 +117,8 @@ export const create = mutation({
internalSummary: v.optional(v.string()), internalSummary: v.optional(v.string()),
publicSummary: v.optional(v.string()), publicSummary: v.optional(v.string()),
publicBody: v.optional(v.string()), publicBody: v.optional(v.string()),
publicObservations: v.optional(v.array(publicObservationValidator)),
publicOffer: v.optional(publicOfferValidator),
ctaType: v.optional(v.string()), ctaType: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -89,6 +172,8 @@ export const upsertFromAuditGeneration = internalMutation({
multimodalSummary: v.optional(v.string()), multimodalSummary: v.optional(v.string()),
publicSummary: v.optional(v.string()), publicSummary: v.optional(v.string()),
publicBody: v.optional(v.string()), publicBody: v.optional(v.string()),
publicObservations: v.optional(v.array(publicObservationValidator)),
publicOffer: v.optional(publicOfferValidator),
usedSkills: v.optional(usedSkillsValidator), usedSkills: v.optional(usedSkillsValidator),
skillSummaries: v.optional(skillSummaryValidator), skillSummaries: v.optional(skillSummaryValidator),
}, },
@@ -112,6 +197,8 @@ export const upsertFromAuditGeneration = internalMutation({
multimodalSummary: args.multimodalSummary, multimodalSummary: args.multimodalSummary,
publicSummary: args.publicSummary, publicSummary: args.publicSummary,
publicBody: args.publicBody, publicBody: args.publicBody,
publicObservations: args.publicObservations,
publicOffer: args.publicOffer,
usedSkills: args.usedSkills, usedSkills: args.usedSkills,
skillSummaries: args.skillSummaries, skillSummaries: args.skillSummaries,
updatedAt: now, updatedAt: now,
@@ -145,6 +232,8 @@ export const upsertFromAuditGeneration = internalMutation({
multimodalSummary: args.multimodalSummary, multimodalSummary: args.multimodalSummary,
publicSummary: args.publicSummary, publicSummary: args.publicSummary,
publicBody: args.publicBody, publicBody: args.publicBody,
publicObservations: args.publicObservations,
publicOffer: args.publicOffer,
usedSkills: args.usedSkills, usedSkills: args.usedSkills,
skillSummaries: args.skillSummaries, skillSummaries: args.skillSummaries,
createdAt: now, 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({ export const list = query({
args: { args: {
leadId: v.optional(v.id("leads")), leadId: v.optional(v.id("leads")),

View File

@@ -3,6 +3,7 @@
import { api, internal } from "./_generated/api"; import { api, internal } from "./_generated/api";
import { internalAction } from "./_generated/server"; import { internalAction } from "./_generated/server";
import type { Id } from "./_generated/dataModel"; import type { Id } from "./_generated/dataModel";
import type { ActionCtx } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { import {
classifyPageSpeedError, 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({ export const processPageSpeedAudit = internalAction({
args: { args: {
runId: v.id("agentRuns"), runId: v.id("agentRuns"),
@@ -109,15 +148,7 @@ export const processPageSpeedAudit = internalAction({
const apiKeyRaw = process.env.PAGESPEED_API_KEY?.trim(); const apiKeyRaw = process.env.PAGESPEED_API_KEY?.trim();
const apiKey = apiKeyRaw ? apiKeyRaw : undefined; const apiKey = apiKeyRaw ? apiKeyRaw : undefined;
let started: let started: StartedPageSpeedAudit | null = null;
| {
lead: {
_id: Id<"leads">;
websiteUrl: string;
};
auditId?: Id<"audits">;
}
| null = null;
try { try {
started = await ctx.runMutation(internal.pageSpeed.startPageSpeedAuditRun, { started = await ctx.runMutation(internal.pageSpeed.startPageSpeedAuditRun, {
@@ -267,6 +298,8 @@ export const processPageSpeedAudit = internalAction({
: undefined, : undefined,
}); });
await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started);
return args.runId; return args.runId;
} catch (error) { } catch (error) {
const { errorSummary } = classifyPageSpeedFailure(error, apiKeyRaw); const { errorSummary } = classifyPageSpeedFailure(error, apiKeyRaw);
@@ -283,6 +316,7 @@ export const processPageSpeedAudit = internalAction({
message: "PageSpeed-Analyse fehlgeschlagen.", message: "PageSpeed-Analyse fehlgeschlagen.",
details: [{ label: "Fehler", value: errorSummary, source: "pagespeed_action" }], details: [{ label: "Fehler", value: errorSummary, source: "pagespeed_action" }],
}); });
await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started);
return null; return null;
} }
}, },

View File

@@ -156,6 +156,18 @@ const playwrightSummary = v.object({
formsFound: v.number(), formsFound: v.number(),
notes: v.optional(v.array(v.string())), 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({ const eventDetail = v.object({
label: v.string(), label: v.string(),
value: v.string(), value: v.string(),
@@ -285,6 +297,8 @@ export default defineSchema({
internalSummary: v.optional(v.string()), internalSummary: v.optional(v.string()),
publicSummary: v.optional(v.string()), publicSummary: v.optional(v.string()),
publicBody: v.optional(v.string()), publicBody: v.optional(v.string()),
publicObservations: v.optional(v.array(publicAuditObservation)),
publicOffer: v.optional(publicAuditOffer),
ctaType: v.optional(v.string()), ctaType: v.optional(v.string()),
publishedAt: v.optional(v.number()), publishedAt: v.optional(v.number()),
reviewDueAt: v.optional(v.number()), reviewDueAt: v.optional(v.number()),

View File

@@ -0,0 +1 @@
export const publicAuditCacheTag = (slug: string) => `public-audit:${slug}`;

View File

@@ -0,0 +1,51 @@
import type {
PublicAuditLookupResult,
PublicAuditOffer,
PublicAuditRenderState,
} from "./public-audit-types";
const isSafeCtaHref = (href: string) => {
try {
const parsed = new URL(href);
return parsed.protocol === "https:" || parsed.protocol === "mailto:" || parsed.protocol === "tel:";
} catch {
return href.startsWith("/");
}
};
const sanitizeOffer = (offer: PublicAuditOffer): PublicAuditOffer => {
if (!offer.ctaHref || isSafeCtaHref(offer.ctaHref)) {
return offer;
}
return {
body: offer.body,
ctaLabel: offer.ctaLabel,
};
};
export const toPublicAuditRenderState = (
result: PublicAuditLookupResult,
): PublicAuditRenderState => {
if (!result || result.publicationStatus === "deactivated") {
return { kind: "unavailable" };
}
if (result.publicationStatus !== "published") {
return { kind: "pending" };
}
return {
kind: "published",
audit: {
companyName: result.companyName,
domain: result.domain,
publishedAt: result.publishedAt,
headline: result.publicContent.headline,
intro: result.publicContent.intro,
observations: result.publicContent.observations,
finalOffer: sanitizeOffer(result.publicContent.finalOffer),
screenshots: result.screenshots,
},
};
};

View File

@@ -0,0 +1,8 @@
import { revalidatePath, revalidateTag } from "next/cache";
import { publicAuditCacheTag } from "./public-audit-cache";
export const revalidatePublicAudit = (slug: string) => {
revalidateTag(publicAuditCacheTag(slug), "max");
revalidatePath(`/audit/${slug}`);
};

View File

@@ -0,0 +1,57 @@
export type PublicAuditLookupResult =
| null
| { publicationStatus: "draft" | "approved" | "deactivated" }
| {
publicationStatus: "published";
companyName: string;
domain: string;
publishedAt: string;
publicContent: {
headline: string;
intro: string;
observations: PublicAuditObservation[];
finalOffer: PublicAuditOffer;
};
screenshots: PublicAuditScreenshot[];
};
export type PublicAuditObservation = {
title: string;
observation: string;
impact: string;
suggestion: string;
screenshotIds?: string[];
};
export type PublicAuditOffer = {
body: string;
ctaLabel?: string;
ctaHref?: string;
};
export type PublicAuditScreenshot = {
id: string;
url: string;
alt: string;
viewport: "desktop" | "mobile";
sourceUrl: string;
width: number;
height: number;
};
export type PublicAuditRenderState =
| { kind: "pending" }
| { kind: "unavailable" }
| {
kind: "published";
audit: {
companyName: string;
domain: string;
publishedAt: string;
headline: string;
intro: string;
observations: PublicAuditObservation[];
finalOffer: PublicAuditOffer;
screenshots: PublicAuditScreenshot[];
};
};

40
lib/audits/slugs.ts Normal file
View File

@@ -0,0 +1,40 @@
const MAX_PUBLIC_AUDIT_SLUG_LENGTH = 120;
const transliterations: Record<string, string> = {
ä: "ae",
ö: "oe",
ü: "ue",
ß: "ss",
æ: "ae",
ø: "oe",
å: "a",
};
export const toPublicAuditSlug = (companyName: string, domain: string) => {
const input = `${companyName} ${domain}`.trim().toLowerCase();
const normalized = input
.replace(/[äöüßæøå]/g, (character) => transliterations[character] ?? character)
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-{2,}/g, "-")
.slice(0, MAX_PUBLIC_AUDIT_SLUG_LENGTH)
.replace(/-+$/g, "");
return normalized.length > 0 ? normalized : "audit";
};
export const parsePublicAuditSlug = (slug: string) => {
const normalized = slug.trim().toLowerCase();
if (
normalized.length === 0 ||
normalized.length > MAX_PUBLIC_AUDIT_SLUG_LENGTH ||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(normalized)
) {
return null;
}
return normalized;
};

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ cacheComponents: true,
}; };
export default nextConfig; export default nextConfig;

View File

@@ -99,6 +99,41 @@ test("pageSpeedAction starts and finishes run mutations", () => {
); );
}); });
test("pageSpeedAction queues audit generation after PageSpeed completion", () => {
assert.equal(
hasPattern(actionSource, /internal\.auditGeneration\.queueLeadAuditGeneration/),
true,
"Action should call internal.auditGeneration.queueLeadAuditGeneration after PageSpeed work.",
);
assert.equal(
hasPattern(
actionSource,
/queueAuditGenerationAfterPageSpeed\(\s*ctx,\s*args\.runId,\s*started\s*\)/,
),
true,
"Action should route PageSpeed completion through a shared audit-generation handoff helper.",
);
assert.equal(
hasPattern(
actionSource,
/queueAuditGenerationAfterPageSpeed[\s\S]*leadId:\s*started\.lead\._id[\s\S]*parentRunId:\s*runId/,
),
true,
"The handoff should queue audit generation for the same lead and parent PageSpeed run.",
);
assert.equal(
hasPattern(
actionSource,
/catch \(auditQueueError\)[\s\S]*Audit-Generierung konnte nicht in die Warteschlange gesetzt werden/,
),
true,
"Audit-generation queue failures should be logged without failing PageSpeed completion.",
);
});
test("pageSpeedAction has action-level guard to fail whole run on unexpected errors", () => { test("pageSpeedAction has action-level guard to fail whole run on unexpected errors", () => {
assert.equal( assert.equal(
hasPattern( hasPattern(

View File

@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const source = async (relativePath: string) => {
return await readFile(
join(process.cwd(), ...relativePath.split("/")),
"utf8",
);
};
test("public audit schema stores reviewed public observations and offer separately", async () => {
const schemaSource = await source("convex/schema.ts");
assert.match(schemaSource, /publicObservations/);
assert.match(schemaSource, /observation:\s*v\.string\(\)/);
assert.match(schemaSource, /impact:\s*v\.string\(\)/);
assert.match(schemaSource, /suggestion:\s*v\.string\(\)/);
assert.match(schemaSource, /screenshotIds:\s*v\.optional\(v\.array\(v\.id\("_storage"\)\)\)/);
assert.match(schemaSource, /publicOffer/);
assert.match(schemaSource, /ctaLabel:\s*v\.optional\(v\.string\(\)\)/);
assert.match(schemaSource, /ctaHref:\s*v\.optional\(v\.string\(\)\)/);
});
test("public audit convex query only exposes published, bounded, public data", async () => {
const auditsSource = await source("convex/audits.ts");
assert.match(auditsSource, /export const getPublicBySlug = query/);
assert.match(
auditsSource,
/\.withIndex\("by_slug",\s*\(q\)\s*=>\s*q\.eq\("slug",\s*args\.slug\)\)\s*\.unique\(\)/,
"Public lookup should use the unique slug index.",
);
assert.match(
auditsSource,
/audit\.status !== "published"/,
"Draft and approved audits should not expose public content.",
);
assert.match(auditsSource, /audit\.status === "deactivated"/);
assert.match(auditsSource, /\.withIndex\("by_auditId"/);
assert.match(auditsSource, /\.take\(\s*8\s*\)/);
assert.match(auditsSource, /ctx\.storage\.getUrl\(screenshot\.storageId\)/);
assert.doesNotMatch(
auditsSource.match(/export const getPublicBySlug[\s\S]*?export const/)?.[0] ?? "",
/usedSkills|internalSummary|skillSummaries|pageSpeedSummary/,
"The public query should not return internal audit fields.",
);
});
test("public audit write mutations require authenticated operators", async () => {
const auditsSource = await source("convex/audits.ts");
for (const exportName of [
"savePublicAuditContent",
"publishPublicAudit",
"reapprovePublicAudit",
"deactivatePublicAudit",
]) {
assert.match(auditsSource, new RegExp(`export const ${exportName} = mutation`));
}
assert.match(auditsSource, /ctx\.auth\.getUserIdentity\(\)/);
assert.match(auditsSource, /Nicht autorisiert/);
assert.match(auditsSource, /publishedAt:\s*now/);
assert.match(auditsSource, /deactivatedAt:\s*now/);
});

View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parsePublicAuditSlug, toPublicAuditSlug } from "../lib/audits/slugs";
import { toPublicAuditRenderState } from "../lib/audits/public-audit-presenter";
test("public audit slug helpers normalize German company names without leaking arbitrary path input", () => {
assert.equal(toPublicAuditSlug("Müller & Söhne GmbH", "Example.COM"), "mueller-soehne-gmbh-example-com");
assert.equal(parsePublicAuditSlug("mueller-soehne-gmbh-example-com"), "mueller-soehne-gmbh-example-com");
assert.equal(parsePublicAuditSlug("../secret"), null);
assert.equal(parsePublicAuditSlug("x".repeat(121)), null);
});
test("public audit presenter hides unavailable records and sanitizes external CTA links", () => {
assert.deepEqual(toPublicAuditRenderState(null), { kind: "unavailable" });
assert.deepEqual(toPublicAuditRenderState({ publicationStatus: "draft" }), { kind: "pending" });
assert.deepEqual(toPublicAuditRenderState({ publicationStatus: "deactivated" }), { kind: "unavailable" });
const rendered = toPublicAuditRenderState({
publicationStatus: "published",
companyName: "Lemon Space",
domain: "lemonspace.example",
publishedAt: "2026-06-05T10:00:00.000Z",
publicContent: {
headline: "Mehr Anfragen über die Website",
intro: "Die Website hat gute Grundlagen.",
observations: [
{
title: "Kontakt ist schwer zu finden",
observation: "Der primäre Kontaktweg liegt zu tief.",
impact: "Mehr Absprünge auf mobilen Geräten.",
suggestion: "CTA im ersten sichtbaren Bereich ergänzen.",
},
],
finalOffer: {
body: "Wir priorisieren die nächsten Verbesserungen gemeinsam.",
ctaLabel: "Audit besprechen",
ctaHref: "javascript:alert(1)",
},
},
screenshots: [],
});
assert.equal(rendered.kind, "published");
if (rendered.kind !== "published") {
return;
}
assert.equal(rendered.audit.finalOffer.ctaHref, undefined);
assert.equal(rendered.audit.observations[0]?.impact, "Mehr Absprünge auf mobilen Geräten.");
});

View File

@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const source = async (relativePath: string) => {
return await readFile(
join(process.cwd(), ...relativePath.split("/")),
"utf8",
);
};
test("public audit revalidation route requires a secret and slug before invalidating cache", async () => {
const routeSource = await source("app/api/internal/revalidate-public-audit/route.ts");
assert.match(routeSource, /PUBLIC_AUDIT_REVALIDATION_SECRET/);
assert.match(routeSource, /request\.headers\.get\("authorization"\)/);
assert.match(routeSource, /Bearer \$\{secret\}/);
assert.match(routeSource, /parsePublicAuditSlug/);
assert.match(routeSource, /revalidatePublicAudit\(normalizedSlug\)/);
assert.match(routeSource, /NextResponse\.json\(\{\s*ok:\s*true/);
});

View File

@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const source = async (relativePath: string) => {
return await readFile(
join(process.cwd(), ...relativePath.split("/")),
"utf8",
);
};
test("public audit route uses cache components and never leaks the requested slug in fallback states", async () => {
const routeSource = await source("app/audit/[slug]/page.tsx");
const configSource = await source("next.config.ts");
assert.match(configSource, /cacheComponents:\s*true/);
assert.match(routeSource, /params:\s*Promise<\{\s*slug:\s*string\s*\}>/);
assert.match(routeSource, /<Suspense/);
assert.match(routeSource, /cacheTag\(publicAuditCacheTag\(normalizedSlug\)\)/);
assert.match(routeSource, /cacheLife\("days"\)/);
assert.match(routeSource, /api\.audits\.getPublicBySlug/);
assert.match(routeSource, /robots:\s*\{[\s\S]*index:\s*false/);
assert.doesNotMatch(routeSource, /Audit:\s*\{slug\}/);
assert.doesNotMatch(routeSource, /Verwendete Skills|usedSkills/i);
});
test("public audit presentation renders observations, screenshots, and final offer", async () => {
const pageSource = await source("components/public-audit/public-audit-page.tsx");
const screenshotSource = await source("components/public-audit/public-audit-screenshot.tsx");
const statusSource = await source("components/public-audit/public-audit-status.tsx");
assert.match(pageSource, /observation\.observation/);
assert.match(pageSource, /observation\.impact/);
assert.match(pageSource, /observation\.suggestion/);
assert.match(pageSource, /audit\.screenshots\.map/);
assert.match(pageSource, /audit\.finalOffer/);
assert.match(pageSource, /href=\{audit\.finalOffer\.ctaHref\}/);
assert.match(screenshotSource, /alt=\{screenshot\.alt\}/);
assert.match(statusSource, /Dieser Audit ist noch nicht freigegeben/);
assert.match(statusSource, /Dieser Audit ist nicht verfügbar/);
});
test("sitemap stays indexable while excluding public audit URLs", async () => {
const sitemapSource = await source("app/sitemap.ts");
assert.match(sitemapSource, /MetadataRoute\.Sitemap/);
assert.match(sitemapSource, /NEXT_PUBLIC_SITE_URL/);
assert.doesNotMatch(sitemapSource, /\/audit\//);
});