Add MVP operational readiness checks

This commit is contained in:
2026-06-05 21:46:16 +02:00
parent df8ca1f049
commit d3928d61c4
7 changed files with 341 additions and 7 deletions

View File

@@ -1,10 +1,6 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; import { OperationsReadiness } from "@/components/settings/operations-readiness";
import { getIntegrationReadiness } from "@/lib/operational-readiness";
export default function SettingsPage() { export default function SettingsPage() {
return ( return <OperationsReadiness rows={getIntegrationReadiness(process.env)} />;
<DashboardPlaceholderPage
description="Provider-Status, Secrets-Hinweise und Workspace-Einstellungen folgen mit den Integrationen."
title="Einstellungen"
/>
);
} }

View File

@@ -0,0 +1,61 @@
import { AlertTriangle, CheckCircle2 } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { IntegrationReadinessRow } from "@/lib/operational-readiness";
type OperationsReadinessProps = {
rows: IntegrationReadinessRow[];
};
export function OperationsReadiness({ rows }: OperationsReadinessProps) {
return (
<section className="space-y-4">
<header className="border-b pb-3">
<p className="text-sm text-muted-foreground">MVP-Betrieb</p>
<h1 className="mt-2 text-3xl font-semibold tracking-normal">
Einstellungen
</h1>
</header>
<Card>
<CardHeader>
<CardTitle>Integrationsstatus</CardTitle>
<CardDescription>
Diese Übersicht zeigt nur fehlende Variablennamen und keine Secret-Werte.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2">
{rows.map((row) => {
const isConfigured = row.status === "configured";
const Icon = isConfigured ? CheckCircle2 : AlertTriangle;
return (
<article className="rounded-lg border p-4" key={row.id}>
<div className="flex items-start gap-3">
<Icon
aria-hidden
className={isConfigured ? "mt-0.5 size-5 text-emerald-600" : "mt-0.5 size-5 text-amber-600"}
/>
<div className="min-w-0">
<h2 className="text-base font-semibold">{row.label}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{isConfigured ? "Konfiguration vorhanden" : "Konfiguration fehlt"}
</p>
{row.missingEnv.length > 0 ? (
<p className="mt-2 break-words text-xs text-muted-foreground">
Fehlend: {row.missingEnv.join(", ")}
</p>
) : null}
<p className="mt-2 text-xs text-muted-foreground">
{row.errorSurface}
</p>
</div>
</div>
</article>
);
})}
</CardContent>
</Card>
</section>
);
}

View File

@@ -0,0 +1,50 @@
# Coolify Deployment
## Environment Variables
Set production values in Coolify and Convex secrets, not in source code.
- `APP_ENV`
- `NEXT_PUBLIC_APP_URL`
- `NEXT_PUBLIC_CONVEX_URL`
- `NEXT_PUBLIC_CONVEX_SITE_URL`
- `CONVEX_DEPLOYMENT`
- `BETTER_AUTH_SECRET`
- `GOOGLE_GEOCODING_API_KEY`
- `GOOGLE_PLACES_API_KEY`
- `PAGESPEED_API_KEY`
- `PAGESPEED_TIMEOUT_MS`
- `OPENROUTER_API_KEY`
- `OPENROUTER_MODEL_CLASSIFICATION`
- `OPENROUTER_MODEL_MULTIMODAL_AUDIT`
- `OPENROUTER_MODEL_GERMAN_COPY`
- `OPENROUTER_MODEL_QUALITY_REVIEW`
- `OPENROUTER_APP_NAME`
- `OPENROUTER_APP_URL`
- `SMTP_HOST`
- `SMTP_PORT`
- `SMTP_USER`
- `SMTP_PASSWORD`
- `SMTP_FROM`
- `RYBBIT_API_URL`
- `RYBBIT_API_KEY`
- `NEXT_PUBLIC_RYBBIT_SITE_ID`
- `TASK8_BROWSER_ASSET_URL`
## Build And Runtime
Coolify commands:
- Install: `pnpm install --frozen-lockfile`
- Build: `pnpm build`
- Start: `pnpm start`
Expose Port 3000 from the Next.js container.
## Playwright
Website enrichment uses `playwright-core` with a hosted Chromium bundle. Configure `TASK8_BROWSER_ASSET_URL` to a reachable browser asset. If the platform image also installs system browser dependencies, keep them aligned with the Chromium bundle used by `@sparticuz/chromium-min`.
## Domains
Set `NEXT_PUBLIC_APP_URL` to the public dashboard Domain. Configure Convex deployment URLs in `NEXT_PUBLIC_CONVEX_URL` and `NEXT_PUBLIC_CONVEX_SITE_URL`. Public audit links assume the same app domain unless a reverse proxy maps `/audit/*` separately.

46
docs/verification.md Normal file
View File

@@ -0,0 +1,46 @@
# MVP Verification Notes
Diese Checkliste ist die wiederholbare manuelle Prüfung für die kritischen MVP-Flows.
## Login
1. `/login` öffnen.
2. Mit Admin-Zugang anmelden.
3. Prüfen, dass `/dashboard` erreichbar ist und geschützte Routen ohne Session zurück zu `/login` gehen.
## Kampagnenlauf
1. Kampagne mit deutscher PLZ und aktivem Status anlegen.
2. `Jetzt ausführen` starten.
3. In Kampagnen-Run-Logs prüfen, dass der Lauf `pending/running/succeeded` oder ein sichtbarer Fehlerstatus wird.
## Audit-Generierung
1. Lead mit Website durch Enrichment/PageSpeed laufen lassen.
2. Prüfen, dass PageSpeed-Erfolg oder -Fehler Audit-Generierung queued.
3. Im Outreach Review Workspace prüfen, dass Audit-Text, Quellen und Skills sichtbar sind.
## Freigabe
1. Public-Audit-Text editieren.
2. Änderungen speichern.
3. Audit veröffentlichen und öffentliche Audit-URL öffnen.
## Versand
1. E-Mail-Betreff und Text prüfen.
2. E-Mail freigeben.
3. Finale SMTP-Bestätigung kontrollieren und senden.
4. Bei SMTP-Fehler prüfen, dass der Datensatz retrybar bleibt und keine Credentials angezeigt werden.
## Follow-up
1. Nach Erstversand prüfen, dass ein Follow-up-Draft mit Due-Date entsteht.
2. Follow-up erst nach manueller Review/Freigabe senden.
3. `Antwort erhalten` oder `Kein Interesse` setzen und prüfen, dass Follow-up-Prompts verschwinden.
## Analytics
1. Öffentliche Audit-Seite öffnen und CTA klicken.
2. `/dashboard/analytics` prüfen.
3. Convex-Metriken und Rybbit-Fehlerzustand bzw. Rybbit-Signale kontrollieren.

View File

@@ -0,0 +1,82 @@
export type IntegrationReadinessStatus = "configured" | "missing";
export type IntegrationReadinessDefinition = {
id:
| "google"
| "pagespeed"
| "openrouter"
| "playwright"
| "smtp"
| "convex_jobs"
| "rybbit";
label: string;
requiredEnv: string[];
errorSurface: string;
};
export type IntegrationReadinessRow = IntegrationReadinessDefinition & {
status: IntegrationReadinessStatus;
missingEnv: string[];
};
export const integrationReadinessDefinitions: IntegrationReadinessDefinition[] = [
{
id: "google",
label: "Google",
requiredEnv: ["GOOGLE_GEOCODING_API_KEY", "GOOGLE_PLACES_API_KEY"],
errorSurface: "Run-Events der Lead-Recherche zeigen Google-Fehler.",
},
{
id: "pagespeed",
label: "PageSpeed",
requiredEnv: ["PAGESPEED_API_KEY", "PAGESPEED_TIMEOUT_MS"],
errorSurface: "PageSpeed-Run-Events und Audit-Quellen zeigen Fehlerdetails.",
},
{
id: "openrouter",
label: "OpenRouter",
requiredEnv: ["OPENROUTER_API_KEY"],
errorSurface: "Audit-Generierungsruns zeigen Modell- und Guard-Fehler.",
},
{
id: "playwright",
label: "Playwright",
requiredEnv: ["TASK8_BROWSER_ASSET_URL"],
errorSurface: "Website-Enrichment-Runs zeigen Browser- und Crawl-Fehler.",
},
{
id: "smtp",
label: "SMTP",
requiredEnv: ["SMTP_HOST", "SMTP_USER", "SMTP_PASSWORD", "SMTP_FROM"],
errorSurface: "Outreach-Sendeversuche zeigen SMTP-Fehler ohne Credentials.",
},
{
id: "convex_jobs",
label: "Convex Jobs",
requiredEnv: ["NEXT_PUBLIC_CONVEX_URL", "CONVEX_DEPLOYMENT"],
errorSurface: "Kampagnen-Run-Logs und Lifecycle-Runs zeigen Job-Status.",
},
{
id: "rybbit",
label: "Rybbit",
requiredEnv: ["RYBBIT_API_URL", "RYBBIT_API_KEY", "NEXT_PUBLIC_RYBBIT_SITE_ID"],
errorSurface: "Analytics zeigt API-Fehler als nicht blockierende Meldung.",
},
];
export function getIntegrationReadiness(
env: Record<string, string | undefined>,
): IntegrationReadinessRow[] {
return integrationReadinessDefinitions.map((definition) => {
const missingEnv = definition.requiredEnv.filter((key) => {
const value = env[key];
return !value || value.trim().length === 0;
});
return {
...definition,
missingEnv,
status: missingEnv.length === 0 ? "configured" : "missing",
};
});
}

View File

@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getIntegrationReadiness,
integrationReadinessDefinitions,
} from "../lib/operational-readiness";
test("integration readiness covers all MVP providers", () => {
assert.deepEqual(
integrationReadinessDefinitions.map((definition) => definition.id),
[
"google",
"pagespeed",
"openrouter",
"playwright",
"smtp",
"convex_jobs",
"rybbit",
],
);
});
test("integration readiness reports missing configuration without leaking values", () => {
const rows = getIntegrationReadiness({
GOOGLE_GEOCODING_API_KEY: "secret-google",
GOOGLE_PLACES_API_KEY: "secret-places",
PAGESPEED_API_KEY: "",
});
const google = rows.find((row) => row.id === "google");
const pageSpeed = rows.find((row) => row.id === "pagespeed");
assert.equal(google?.status, "configured");
assert.equal(pageSpeed?.status, "missing");
assert.equal(JSON.stringify(rows).includes("secret-google"), false);
assert.equal(JSON.stringify(rows).includes("secret-places"), false);
});

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
function source(path: string) {
return readFileSync(join(process.cwd(), ...path.split("/")), "utf8");
}
test("settings page surfaces integration status instead of a placeholder", () => {
const pageSource = source("app/dashboard/settings/page.tsx");
const componentSource = source("components/settings/operations-readiness.tsx");
const helperSource = source("lib/operational-readiness.ts");
assert.doesNotMatch(pageSource, /DashboardPlaceholderPage/);
assert.match(pageSource, /OperationsReadiness/);
for (const label of [
"Google",
"PageSpeed",
"OpenRouter",
"Playwright",
"SMTP",
"Convex Jobs",
"Rybbit",
"Konfiguration fehlt",
]) {
assert.match(`${componentSource}\n${helperSource}`, new RegExp(label));
}
});
test("verification notes cover critical MVP flows", () => {
const docPath = join(process.cwd(), "docs", "verification.md");
assert.equal(existsSync(docPath), true);
const doc = readFileSync(docPath, "utf8");
for (const label of [
"Login",
"Kampagnenlauf",
"Audit-Generierung",
"Freigabe",
"Versand",
"Follow-up",
"Analytics",
]) {
assert.match(doc, new RegExp(label));
}
});
test("Coolify deployment notes cover env vars, Playwright dependencies, port, and domains", () => {
const docPath = join(process.cwd(), "docs", "coolify-deployment.md");
assert.equal(existsSync(docPath), true);
const doc = readFileSync(docPath, "utf8");
assert.match(doc, /Environment Variables/);
assert.match(doc, /TASK8_BROWSER_ASSET_URL/);
assert.match(doc, /Playwright/);
assert.match(doc, /Port 3000/);
assert.match(doc, /NEXT_PUBLIC_APP_URL/);
assert.match(doc, /Domain/);
});