Compare commits
2 Commits
03cb65fde4
...
1feccb9bdf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1feccb9bdf | ||
|
|
47ee2c2d51 |
25
app/api/internal/revalidate-public-audit/route.ts
Normal file
25
app/api/internal/revalidate-public-audit/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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<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 (
|
||||
<main className="flex min-h-dvh items-center justify-center bg-background px-6 py-12">
|
||||
<section className="w-full max-w-2xl rounded-lg border bg-card p-6 text-card-foreground">
|
||||
<FileText className="mb-5 size-6 text-muted-foreground" />
|
||||
<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>
|
||||
<Suspense fallback={<PublicAuditStatus status="pending" />}>
|
||||
<PublicAuditContent params={params} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
7
app/audit/layout.tsx
Normal file
7
app/audit/layout.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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 <ConvexClientProvider initialToken={token}>{children}</ConvexClientProvider>;
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="de"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<ConvexClientProvider initialToken={token}>{children}</ConvexClientProvider>
|
||||
<Suspense fallback={null}>
|
||||
<AuthenticatedConvexProvider>{children}</AuthenticatedConvexProvider>
|
||||
</Suspense>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
14
app/sitemap.ts
Normal file
14
app/sitemap.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #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
|
||||
<!-- AC:END -->
|
||||
|
||||
## 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.
|
||||
<!-- 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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
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
|
||||
<!-- 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 -->
|
||||
|
||||
@@ -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 -->
|
||||
124
components/public-audit/public-audit-page.tsx
Normal file
124
components/public-audit/public-audit-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
components/public-audit/public-audit-screenshot.tsx
Normal file
26
components/public-audit/public-audit-screenshot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
components/public-audit/public-audit-status.tsx
Normal file
27
components/public-audit/public-audit-status.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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()),
|
||||
|
||||
1
lib/audits/public-audit-cache.ts
Normal file
1
lib/audits/public-audit-cache.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const publicAuditCacheTag = (slug: string) => `public-audit:${slug}`;
|
||||
51
lib/audits/public-audit-presenter.ts
Normal file
51
lib/audits/public-audit-presenter.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
8
lib/audits/public-audit-revalidation.ts
Normal file
8
lib/audits/public-audit-revalidation.ts
Normal 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}`);
|
||||
};
|
||||
57
lib/audits/public-audit-types.ts
Normal file
57
lib/audits/public-audit-types.ts
Normal 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
40
lib/audits/slugs.ts
Normal 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;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
cacheComponents: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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(
|
||||
|
||||
67
tests/public-audit-contract.test.ts
Normal file
67
tests/public-audit-contract.test.ts
Normal 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/);
|
||||
});
|
||||
51
tests/public-audit-presenter.test.ts
Normal file
51
tests/public-audit-presenter.test.ts
Normal 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.");
|
||||
});
|
||||
22
tests/public-audit-revalidation-route.test.ts
Normal file
22
tests/public-audit-revalidation-route.test.ts
Normal 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/);
|
||||
});
|
||||
50
tests/public-audit-ui.test.ts
Normal file
50
tests/public-audit-ui.test.ts
Normal 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\//);
|
||||
});
|
||||
Reference in New Issue
Block a user