diff --git a/app/api/internal/revalidate-public-audit/route.ts b/app/api/internal/revalidate-public-audit/route.ts new file mode 100644 index 0000000..f303c82 --- /dev/null +++ b/app/api/internal/revalidate-public-audit/route.ts @@ -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 }); +} diff --git a/app/audit/[slug]/page.tsx b/app/audit/[slug]/page.tsx index 0ec5638..8ba7a93 100644 --- a/app/audit/[slug]/page.tsx +++ b/app/audit/[slug]/page.tsx @@ -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({ - params, -}: { +import { PublicAuditPage } from "@/components/public-audit/public-audit-page"; +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 }>; -}) { - const { slug } = await params; +}; +async function getCachedPublicAudit(slug: string): Promise { + "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 ; + } + + if (renderState.kind === "unavailable") { + return ; + } + + return ; +} + +export default function PublicAuditRoute({ params }: PublicAuditRouteProps) { return ( -
-
- -

- Audit: {slug} -

-

- Dieser Audit ist noch nicht freigegeben -

-

- Sobald der Bericht manuell geprueft und veroeffentlicht wurde, - erscheinen hier die freigegebenen Beobachtungen und Empfehlungen. -

-
-
+ }> + + ); } diff --git a/app/audit/layout.tsx b/app/audit/layout.tsx new file mode 100644 index 0000000..028b6bd --- /dev/null +++ b/app/audit/layout.tsx @@ -0,0 +1,7 @@ +export default function AuditLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return
{children}
; +} diff --git a/app/layout.tsx b/app/layout.tsx index 1915fbb..91bb475 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { Suspense } from "react"; import { ConvexClientProvider } from "@/components/convex-client-provider"; import { getToken } from "@/lib/auth-server"; import "./globals.css"; @@ -19,20 +20,30 @@ export const metadata: Metadata = { description: "Interner Akquise-Agent fuer lokale Webdesign-Leads", }; -export default async function RootLayout({ +async function AuthenticatedConvexProvider({ children, }: Readonly<{ children: React.ReactNode; }>) { const token = await getToken(); + return {children}; +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { return ( - {children} + + {children} + ); diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..943621a --- /dev/null +++ b/app/sitemap.ts @@ -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, + }, + ]; +} diff --git a/backlog/tasks/task-12 - Publish-customer-audit-pages-with-manual-approval.md b/backlog/tasks/task-12 - Publish-customer-audit-pages-with-manual-approval.md index 25e3a92..e8e499b 100644 --- a/backlog/tasks/task-12 - Publish-customer-audit-pages-with-manual-approval.md +++ b/backlog/tasks/task-12 - Publish-customer-audit-pages-with-manual-approval.md @@ -1,9 +1,10 @@ --- id: TASK-12 title: Publish customer audit pages with manual approval -status: To Do +status: Done assignee: [] created_date: '2026-06-03 19:14' +updated_date: '2026-06-05 12:13' labels: - mvp - audit @@ -24,11 +25,11 @@ Build the public customer-facing audit page system under the audit domain. Pages ## Acceptance Criteria -- [ ] #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 -- [ ] #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 -- [ ] #5 Approved pages are cached and cache is invalidated when the audit is edited and re-approved +- [x] #1 Public audit pages render approved audit content with company name, domain, screenshots, observations, impact, suggestions, and final offer/CTA +- [x] #2 Unapproved audit URLs show Dieser Audit ist noch nicht freigegeben without leaking company details +- [x] #3 Deactivated audit URLs show a neutral unavailable message without exposing audit content +- [x] #4 Audit pages are noindex, excluded from sitemap/public listing, and use a calm fixed light design +- [x] #5 Approved pages are cached and cache is invalidated when the audit is edited and re-approved ## 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. 5. Add cache/revalidation behavior tied to approval and update actions. + +## Implementation Notes + + +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. + + +## Final Summary + + +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. + diff --git a/backlog/tasks/task-13 - Build-the-audit-and-outreach-review-workspace.md b/backlog/tasks/task-13 - Build-the-audit-and-outreach-review-workspace.md index a553d17..59c0809 100644 --- a/backlog/tasks/task-13 - Build-the-audit-and-outreach-review-workspace.md +++ b/backlog/tasks/task-13 - Build-the-audit-and-outreach-review-workspace.md @@ -1,9 +1,10 @@ --- id: TASK-13 title: Build the audit and outreach review workspace -status: To Do +status: In Progress assignee: [] created_date: '2026-06-03 19:14' +updated_date: '2026-06-05 12:13' labels: - mvp - review @@ -35,9 +36,16 @@ Create the internal review workspace where Matthias can inspect and edit the fin ## Implementation Plan -1. Build review route/detail UI with tabs for Audit, E-Mail, Telefon, Quellen, Rohdaten, and Skills. -2. Add edit forms for audit text, email subject/body, phone script, and follow-up. -3. Add approval actions for audit publication and separate email sending readiness. -4. Show source/contact confidence without exposing unnecessary raw noise by default. -5. Verify state transitions back into the Kanban/Funnel. +1. Wire PageSpeed completion into audit_generation queue +2. Verify handoff with regression tests +3. Build review workspace UI and edit/approval flows +4. Verify state transitions back into dashboard/funnel + +## Implementation Notes + + +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. + diff --git a/backlog/tasks/task-27 - Trigger-audit-generation-after-PageSpeed-audit.md b/backlog/tasks/task-27 - Trigger-audit-generation-after-PageSpeed-audit.md new file mode 100644 index 0000000..cc97176 --- /dev/null +++ b/backlog/tasks/task-27 - Trigger-audit-generation-after-PageSpeed-audit.md @@ -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 + + +Wire the existing AI audit generation queue into the current automated flow so completed PageSpeed audit runs schedule audit_generation for the same lead. + + +## Acceptance Criteria + +- [ ] #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 + + +## Implementation Notes + + +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. + diff --git a/components/public-audit/public-audit-page.tsx b/components/public-audit/public-audit-page.tsx new file mode 100644 index 0000000..5de8631 --- /dev/null +++ b/components/public-audit/public-audit-page.tsx @@ -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["audit"]; +}; + +export function PublicAuditPage({ audit }: PublicAuditPageProps) { + return ( +
+
+
+
+

+ Öffentliche Audit-Kurzfassung +

+

+ {audit.headline} +

+

{audit.intro}

+
+
+
Unternehmen
+
{audit.companyName}
+
+
+
Geprüfte Domain
+
{audit.domain}
+
+
+
+ + +
+
+ +
+
+ {audit.observations.map((observation, index) => ( +
+
+ +
+

+ {observation.title} +

+
+
+

Beobachtung

+

+ {observation.observation} +

+
+
+

Auswirkung

+

+ {observation.impact} +

+
+
+

Vorschlag

+

+ {observation.suggestion} +

+
+
+
+
+
+ ))} +
+
+ + {audit.screenshots.length > 0 ? ( +
+
+

+ Screenshots aus der Prüfung +

+
+ {audit.screenshots.map((screenshot) => ( + + ))} +
+
+
+ ) : null} + +
+
+
+

+ Nächster sinnvoller Schritt +

+

+ {audit.finalOffer.body} +

+
+ {audit.finalOffer.ctaHref ? ( + + {audit.finalOffer.ctaLabel ?? "Audit besprechen"} + {audit.finalOffer.ctaHref.startsWith("/") ? ( + + ) : ( + + )} + + ) : null} +
+
+
+ ); +} diff --git a/components/public-audit/public-audit-screenshot.tsx b/components/public-audit/public-audit-screenshot.tsx new file mode 100644 index 0000000..5c708c8 --- /dev/null +++ b/components/public-audit/public-audit-screenshot.tsx @@ -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 ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {screenshot.alt} +
+ + {screenshot.viewport === "desktop" ? "Desktop" : "Mobil"} + + {screenshot.sourceUrl} +
+
+ ); +} diff --git a/components/public-audit/public-audit-status.tsx b/components/public-audit/public-audit-status.tsx new file mode 100644 index 0000000..966a025 --- /dev/null +++ b/components/public-audit/public-audit-status.tsx @@ -0,0 +1,27 @@ +type PublicAuditStatusProps = { + status?: "pending" | "unavailable"; +}; + +export function PublicAuditStatus({ status = "pending" }: PublicAuditStatusProps) { + const isUnavailable = status === "unavailable"; + + return ( +
+
+

+ Website-Audit +

+

+ {isUnavailable + ? "Dieser Audit ist nicht verfügbar" + : "Dieser Audit ist noch nicht freigegeben"} +

+

+ {isUnavailable + ? "Die angeforderte öffentliche Kurzfassung wurde entfernt oder existiert nicht." + : "Die öffentliche Kurzfassung wird erst nach manueller Prüfung angezeigt."} +

+
+
+ ); +} diff --git a/convex/audits.ts b/convex/audits.ts index bbdc617..2c4a44f 100644 --- a/convex/audits.ts +++ b/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")), diff --git a/convex/pageSpeedAction.ts b/convex/pageSpeedAction.ts index 5352300..2703e47 100644 --- a/convex/pageSpeedAction.ts +++ b/convex/pageSpeedAction.ts @@ -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; } }, diff --git a/convex/schema.ts b/convex/schema.ts index 0e0b36d..8e0a2d7 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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()), diff --git a/lib/audits/public-audit-cache.ts b/lib/audits/public-audit-cache.ts new file mode 100644 index 0000000..47257a4 --- /dev/null +++ b/lib/audits/public-audit-cache.ts @@ -0,0 +1 @@ +export const publicAuditCacheTag = (slug: string) => `public-audit:${slug}`; diff --git a/lib/audits/public-audit-presenter.ts b/lib/audits/public-audit-presenter.ts new file mode 100644 index 0000000..6971175 --- /dev/null +++ b/lib/audits/public-audit-presenter.ts @@ -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, + }, + }; +}; diff --git a/lib/audits/public-audit-revalidation.ts b/lib/audits/public-audit-revalidation.ts new file mode 100644 index 0000000..da0c3e1 --- /dev/null +++ b/lib/audits/public-audit-revalidation.ts @@ -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}`); +}; diff --git a/lib/audits/public-audit-types.ts b/lib/audits/public-audit-types.ts new file mode 100644 index 0000000..c8b4da2 --- /dev/null +++ b/lib/audits/public-audit-types.ts @@ -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[]; + }; + }; diff --git a/lib/audits/slugs.ts b/lib/audits/slugs.ts new file mode 100644 index 0000000..e4b4ede --- /dev/null +++ b/lib/audits/slugs.ts @@ -0,0 +1,40 @@ +const MAX_PUBLIC_AUDIT_SLUG_LENGTH = 120; + +const transliterations: Record = { + ä: "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; +}; diff --git a/next.config.ts b/next.config.ts index e9ffa30..584cbfc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + cacheComponents: true, }; export default nextConfig; diff --git a/tests/pagespeed-action-source.test.ts b/tests/pagespeed-action-source.test.ts index 43af8f8..840fa33 100644 --- a/tests/pagespeed-action-source.test.ts +++ b/tests/pagespeed-action-source.test.ts @@ -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", () => { assert.equal( hasPattern( diff --git a/tests/public-audit-contract.test.ts b/tests/public-audit-contract.test.ts new file mode 100644 index 0000000..39e4791 --- /dev/null +++ b/tests/public-audit-contract.test.ts @@ -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/); +}); diff --git a/tests/public-audit-presenter.test.ts b/tests/public-audit-presenter.test.ts new file mode 100644 index 0000000..d0b8d3f --- /dev/null +++ b/tests/public-audit-presenter.test.ts @@ -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."); +}); diff --git a/tests/public-audit-revalidation-route.test.ts b/tests/public-audit-revalidation-route.test.ts new file mode 100644 index 0000000..6dfab58 --- /dev/null +++ b/tests/public-audit-revalidation-route.test.ts @@ -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/); +}); diff --git a/tests/public-audit-ui.test.ts b/tests/public-audit-ui.test.ts new file mode 100644 index 0000000..450dc9d --- /dev/null +++ b/tests/public-audit-ui.test.ts @@ -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, / { + 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\//); +});