Add MVP operational readiness checks
This commit is contained in:
@@ -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() {
|
||||
return (
|
||||
<DashboardPlaceholderPage
|
||||
description="Provider-Status, Secrets-Hinweise und Workspace-Einstellungen folgen mit den Integrationen."
|
||||
title="Einstellungen"
|
||||
/>
|
||||
);
|
||||
return <OperationsReadiness rows={getIntegrationReadiness(process.env)} />;
|
||||
}
|
||||
|
||||
61
components/settings/operations-readiness.tsx
Normal file
61
components/settings/operations-readiness.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
docs/coolify-deployment.md
Normal file
50
docs/coolify-deployment.md
Normal 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
46
docs/verification.md
Normal 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.
|
||||
82
lib/operational-readiness.ts
Normal file
82
lib/operational-readiness.ts
Normal 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",
|
||||
};
|
||||
});
|
||||
}
|
||||
38
tests/operational-readiness.test.ts
Normal file
38
tests/operational-readiness.test.ts
Normal 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);
|
||||
});
|
||||
61
tests/ops-quality-source.test.ts
Normal file
61
tests/ops-quality-source.test.ts
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user