feat: build audit outreach review workspace

This commit is contained in:
Matthias
2026-06-05 16:47:22 +02:00
parent 1feccb9bdf
commit 5a42c637c6
15 changed files with 1786 additions and 38 deletions

View File

@@ -146,6 +146,37 @@ test("groupLeadFunnelCards derives review, follow-up, and deferred columns witho
);
});
test("groupLeadFunnelCards keeps approved unsent outreach in the review-open funnel", () => {
const groups = groupLeadFunnelCards([
{
id: "lead-approved-unsent",
companyName: "Optik Meyer",
city: "Freiburg",
priority: "medium",
contactStatus: "new",
blacklistStatus: "clear",
outreach: {
approvalStatus: "approved",
sendStatus: "not_sent",
responseStatus: "none",
salesStatus: "follow_up_planned",
},
},
]);
assert.deepEqual(
groups.map((group) => [group.stage.id, group.cards.map((card) => card.id)]),
[
["missing_contact", []],
["audit_ready", []],
["review_open", ["lead-approved-unsent"]],
["contacted", []],
["follow_up", []],
["deferred", []],
],
);
});
test("toLeadFunnelCard maps blocked priority to deferred stage with blocker label", () => {
const card = toLeadFunnelCard({
id: "lead-blocked",

View File

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
test("dashboard sidebar links do not prefetch protected routes", async () => {
const source = await readFile(
join(process.cwd(), "components", "dashboard-sidebar.tsx"),
"utf8",
);
const linkMatch = source.match(/<Link[\s\S]*?href=\{item\.href\}[\s\S]*?>/);
assert.ok(linkMatch, "Dashboard sidebar should render dashboard nav Links.");
assert.match(linkMatch[0], /prefetch=\{false\}/);
});
test("lead funnel card action links do not fan out prefetches", async () => {
const source = await readFile(
join(process.cwd(), "components", "lead-funnel-board.tsx"),
"utf8",
);
const actionLinkMatch = source.match(
/<Link[\s\S]*?href=\{stageActionHref\[card\.stageId\]\}[\s\S]*?>/,
);
assert.ok(actionLinkMatch, "Lead funnel cards should link to stage actions.");
assert.match(actionLinkMatch[0], /prefetch=\{false\}/);
});

View File

@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const dashboardThemePath = join(
process.cwd(),
"components",
"dashboard-theme.tsx",
);
test("DashboardThemeProvider keeps server and first client render stable", async () => {
const source = await readFile(dashboardThemePath, "utf8");
assert.match(source, /useSyncExternalStore\(/);
assert.match(source, /function getServerDashboardTheme\(\): DashboardTheme \{/);
assert.match(source, /return "light";/);
assert.doesNotMatch(
source,
/useState<DashboardTheme>\(\(\) => \{[\s\S]*?localStorage/,
);
assert.doesNotMatch(source, /setTheme\(/);
});

View File

@@ -0,0 +1,331 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import ts from "typescript";
const outreachPath = join(process.cwd(), "convex", "outreach.ts");
const schemaPath = join(process.cwd(), "convex", "schema.ts");
const outreachSource = existsSync(outreachPath)
? readFileSync(outreachPath, "utf8")
: "";
const schemaSource = existsSync(schemaPath)
? readFileSync(schemaPath, "utf8")
: "";
const sourceFile = ts.createSourceFile(
"outreach.ts",
outreachSource,
ts.ScriptTarget.ES2022,
true,
ts.ScriptKind.TS,
);
function getExportedConstNames(file: ts.SourceFile) {
const names = new Set<string>();
const visit = (node: ts.Node) => {
if (ts.isVariableStatement(node)) {
const isExported = node.modifiers?.some(
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
);
const isConst = Boolean(node.declarationList.flags & ts.NodeFlags.Const);
if (isExported && isConst) {
for (const declaration of node.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(file, visit);
return names;
}
function extractExportSource(name: string) {
const marker = `export const ${name} = `;
const declarationIndex = outreachSource.indexOf(marker);
assert.notEqual(declarationIndex, -1, `Expected declaration for ${name}.`);
const openBraceIndex = outreachSource.indexOf("{", declarationIndex);
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < outreachSource.length; index += 1) {
const char = outreachSource[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
assert.notEqual(end, -1, `Expected balanced braces for ${name}.`);
return outreachSource.slice(openBraceIndex, end + 1);
}
function hasPattern(source: string, pattern: RegExp, message: string) {
assert.equal(pattern.test(source), true, message);
}
function lacksPattern(source: string, pattern: RegExp, message: string) {
assert.equal(pattern.test(source), false, message);
}
test("outreach review module exports authenticated review contracts", () => {
assert.equal(existsSync(outreachPath), true, "outreach.ts should be present.");
const exports = getExportedConstNames(sourceFile);
for (const exportName of [
"listReviewWorkspace",
"saveReviewDraft",
"approveEmailDraft",
]) {
assert.equal(exports.has(exportName), true, `Expected export: ${exportName}`);
}
hasPattern(
outreachSource,
/const requireOperator\s*=\s*async\s*\(\s*ctx:\s*(?:QueryCtx\s*\|\s*MutationCtx|MutationCtx\s*\|\s*QueryCtx)\s*\)/,
"Module should define a local requireOperator helper usable from queries and mutations.",
);
hasPattern(
outreachSource,
/ctx\.auth\.getUserIdentity\(\)/,
"requireOperator should derive the operator identity from Convex auth.",
);
hasPattern(
outreachSource,
/Nicht autorisiert/,
"Unauthenticated review calls should fail clearly.",
);
});
test("listReviewWorkspace is bounded, authenticated, and joins review context", () => {
const listSource = extractExportSource("listReviewWorkspace");
const reviewSource = `${listSource}\n${outreachSource}`;
hasPattern(outreachSource, /export const listReviewWorkspace = query\(/, "Review workspace should be a public query.");
hasPattern(listSource, /requireOperator\(ctx\)/, "Review workspace should require auth.");
hasPattern(listSource, /limit:\s*v\.optional\(v\.number\(\)\)/, "Review workspace should accept optional limit.");
hasPattern(listSource, /normalizeListLimit\(args\.limit\)/, "Review workspace should normalize the requested limit.");
lacksPattern(listSource, /\.collect\(/, "Review workspace must not use unbounded collect().");
hasPattern(
listSource,
/query\("leads"\)[\s\S]*?withIndex\("by_contactStatus_and_updatedAt"[\s\S]*?eq\("contactStatus",\s*"outreach_ready"\)[\s\S]*?\.order\("desc"\)[\s\S]*?\.take\(candidateLimit\)/,
"Review workspace should include newest outreach-ready leads via contactStatus+updatedAt.",
);
for (const [approvalStatus, sendStatus] of [
["draft", "not_sent"],
["draft", "queued"],
["draft", "failed"],
["approved", "not_sent"],
["approved", "queued"],
["approved", "failed"],
]) {
hasPattern(
listSource,
new RegExp(
`query\\("outreachRecords"\\)[\\s\\S]*?withIndex\\("by_approvalStatus_and_sendStatus_and_updatedAt"[\\s\\S]*?eq\\("approvalStatus",\\s*"${approvalStatus}"\\)[\\s\\S]*?eq\\("sendStatus",\\s*"${sendStatus}"\\)[\\s\\S]*?\\.order\\("desc"\\)[\\s\\S]*?\\.take\\(candidateLimit\\)`,
),
`Review workspace should fetch newest ${approvalStatus}/${sendStatus} outreach via combined eligibility+updatedAt index.`,
);
}
lacksPattern(
listSource,
/withIndex\("by_approvalStatus_and_updatedAt"/,
"Review workspace should not depend on approval-only bounded windows for outreach eligibility.",
);
lacksPattern(
listSource,
/withIndex\("by_sendStatus_and_updatedAt"/,
"Review workspace should not depend on send-only bounded windows for outreach eligibility.",
);
hasPattern(
listSource,
/\.\.\.draftNotSentOutreach[\s\S]*\.\.\.draftQueuedOutreach[\s\S]*\.\.\.draftFailedOutreach[\s\S]*\.\.\.approvedNotSentOutreach[\s\S]*\.\.\.approvedQueuedOutreach[\s\S]*\.\.\.approvedFailedOutreach/,
"Review workspace should combine only eligible approval/send-status candidate windows.",
);
hasPattern(
listSource,
/approvalStatus\s*===\s*"draft"[\s\S]*?approvalStatus\s*===\s*"approved"/,
"Review workspace should include draft and approved unsent outreach records.",
);
hasPattern(
listSource,
/sendStatus\s*!==\s*"sent"/,
"Review workspace should exclude sent outreach records.",
);
hasPattern(
listSource,
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.sortAt\s*-\s*a\.sortAt\s*\)/,
"Review rows should be newest first.",
);
hasPattern(listSource, /slice\(0,\s*limit\)/, "Review rows should be capped to the normalized limit.");
for (const tableName of [
"audits",
"auditGenerations",
"pageSpeedResults",
"websiteCrawlPages",
"websiteEmailCandidates",
]) {
hasPattern(
reviewSource,
new RegExp(`query\\("${tableName}"\\)[\\s\\S]*?\\.take\\(\\s*\\d+\\s*\\)`),
`${tableName} join should be bounded with take(n).`,
);
}
for (const fieldName of [
"lead",
"latestOutreach",
"audit",
"auditGenerations",
"usedSkills",
"skillSummaries",
"sourceSummaries",
"pageSpeedResults",
"crawlPages",
"emailCandidates",
]) {
hasPattern(
reviewSource,
new RegExp(`${fieldName}:`),
`Review rows should include ${fieldName}.`,
);
}
});
test("schema defines recency indexes for outreach review bounded reads", () => {
assert.equal(existsSync(schemaPath), true, "schema.ts should be present.");
for (const [indexName, fieldsPattern] of [
["by_contactStatus_and_updatedAt", String.raw`\[\s*"contactStatus",\s*"updatedAt",?\s*\]`],
["by_approvalStatus_and_updatedAt", String.raw`\[\s*"approvalStatus",\s*"updatedAt",?\s*\]`],
["by_sendStatus_and_updatedAt", String.raw`\[\s*"sendStatus",\s*"updatedAt",?\s*\]`],
[
"by_approvalStatus_and_sendStatus_and_updatedAt",
String.raw`\[\s*"approvalStatus",\s*"sendStatus",\s*"updatedAt",?\s*\]`,
],
]) {
hasPattern(
schemaSource,
new RegExp(`\\.index\\("${indexName}",\\s*${fieldsPattern}`),
`Schema should define ${indexName} for newest-first bounded review reads.`,
);
}
});
test("upsertFromAuditGeneration preserves review boundaries for generated copy", () => {
const upsertSource = extractExportSource("upsertFromAuditGeneration");
hasPattern(
outreachSource,
/export const upsertFromAuditGeneration = internalMutation\(/,
"upsertFromAuditGeneration should remain an internal mutation.",
);
hasPattern(upsertSource, /ctx\.db\.get\(args\.leadId\)/, "upsert should verify the lead exists.");
hasPattern(upsertSource, /!lead/, "upsert should reject missing leads.");
hasPattern(upsertSource, /ctx\.db\.get\(args\.auditId\)/, "upsert should load provided audits.");
hasPattern(upsertSource, /!audit/, "upsert should reject missing audits.");
hasPattern(
upsertSource,
/audit\.leadId\s*!==\s*args\.leadId/,
"upsert should reject auditId values that belong to a different lead.",
);
hasPattern(
upsertSource,
/current\.sendStatus\s*===\s*"sent"[\s\S]*?ctx\.db\.insert\(\s*"outreachRecords"/,
"upsert should create a new draft record instead of patching a sent outreach record.",
);
hasPattern(
upsertSource,
/ctx\.db\.patch\(current\._id,[\s\S]*approvalStatus:\s*"draft"/,
"Generated copy changes should reset existing unsent outreach records to draft.",
);
hasPattern(
upsertSource,
/approvalStatus:\s*"draft"[\s\S]*sendStatus:\s*"not_sent"/,
"New generated outreach records should start as unsent drafts.",
);
});
test("sensitive public outreach exports require operators and validate references", () => {
const createSource = extractExportSource("create");
const listSource = extractExportSource("list");
hasPattern(createSource, /requireOperator\(ctx\)/, "create should require operator auth.");
hasPattern(listSource, /requireOperator\(ctx\)/, "list should require operator auth.");
hasPattern(createSource, /ctx\.db\.get\(args\.leadId\)/, "create should verify the lead exists.");
hasPattern(createSource, /!lead/, "create should reject missing leads.");
hasPattern(createSource, /ctx\.db\.get\(args\.auditId\)/, "create should load provided audits.");
hasPattern(createSource, /!audit/, "create should reject missing audits.");
hasPattern(
createSource,
/audit\.leadId\s*!==\s*args\.leadId/,
"create should reject auditId values that belong to a different lead.",
);
});
test("saveReviewDraft validates editable fields and never edits sent records", () => {
const saveSource = extractExportSource("saveReviewDraft");
hasPattern(outreachSource, /export const saveReviewDraft = mutation\(/, "saveReviewDraft should be a mutation.");
hasPattern(saveSource, /requireOperator\(ctx\)/, "saveReviewDraft should require auth.");
hasPattern(saveSource, /id:\s*v\.id\("outreachRecords"\)/, "saveReviewDraft should validate the outreach id.");
hasPattern(saveSource, /strategy:\s*strategy/, "saveReviewDraft should validate strategy with the shared strategy validator.");
for (const fieldName of [
"phoneScript",
"emailSubject",
"emailBody",
"followUpDraft",
]) {
hasPattern(
saveSource,
new RegExp(`${fieldName}:\\s*v\\.optional\\(v\\.string\\(\\)\\)`),
`${fieldName} should be optional editable copy.`,
);
}
hasPattern(saveSource, /ctx\.db\.get\(args\.id\)/, "saveReviewDraft should load the outreach record.");
hasPattern(saveSource, /!outreach/, "saveReviewDraft should reject missing outreach.");
hasPattern(saveSource, /outreach\.sendStatus\s*===\s*"sent"/, "saveReviewDraft should reject sent outreach records.");
hasPattern(saveSource, /approvalStatus:\s*"draft"/, "Saving edits should reset approval to draft.");
hasPattern(saveSource, /updatedAt:\s*now/, "Saving edits should stamp updatedAt.");
hasPattern(saveSource, /ctx\.db\.patch\(args\.id/, "saveReviewDraft should patch the existing outreach record.");
});
test("approveEmailDraft validates approval prerequisites and preserves send separation", () => {
const approveSource = extractExportSource("approveEmailDraft");
hasPattern(outreachSource, /export const approveEmailDraft = mutation\(/, "approveEmailDraft should be a mutation.");
hasPattern(approveSource, /requireOperator\(ctx\)/, "approveEmailDraft should require auth.");
hasPattern(approveSource, /id:\s*v\.id\("outreachRecords"\)/, "approveEmailDraft should validate the outreach id.");
hasPattern(approveSource, /ctx\.db\.get\(args\.id\)/, "approveEmailDraft should load the outreach record.");
hasPattern(approveSource, /!outreach/, "approveEmailDraft should reject missing outreach.");
hasPattern(approveSource, /outreach\.sendStatus\s*===\s*"sent"/, "approveEmailDraft should reject sent outreach records.");
hasPattern(approveSource, /ctx\.db\.get\(outreach\.leadId\)/, "approveEmailDraft should load the linked lead.");
hasPattern(approveSource, /lead\.email\?\.trim\(\)/, "approveEmailDraft should require a trimmed recipient email.");
hasPattern(approveSource, /outreach\.emailSubject\?\.trim\(\)/, "approveEmailDraft should require a trimmed subject.");
hasPattern(approveSource, /outreach\.emailBody\?\.trim\(\)/, "approveEmailDraft should require a trimmed body.");
hasPattern(approveSource, /approvalStatus:\s*"approved"/, "Approval should mark only the approval status.");
hasPattern(approveSource, /updatedAt:\s*now/, "Approval should stamp updatedAt.");
hasPattern(approveSource, /recipient:/, "Approval should return recipient context.");
hasPattern(approveSource, /subject:/, "Approval should return subject context.");
hasPattern(approveSource, /auditSlug:/, "Approval should return audit slug context when available.");
lacksPattern(approveSource, /sendStatus\s*:/, "Approval must not alter sendStatus.");
lacksPattern(approveSource, /ctx\.scheduler|runAfter|runMutation|nodemailer|smtp|smpp|sendEmail/i, "Approval must not queue or send email/SMPP/Nodemailer work.");
});

View File

@@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const outreachPagePath = join(
process.cwd(),
"app",
"dashboard",
"outreach",
"page.tsx",
);
const outreachWorkspacePath = join(
process.cwd(),
"components",
"outreach",
"outreach-review-workspace.tsx",
);
function extractConstFunction(source: string, name: string) {
const declaration = `const ${name} = async`;
const start = source.indexOf(declaration);
assert.ok(start >= 0, `${name} handler should exist.`);
const firstBrace = source.indexOf("{", start);
assert.ok(firstBrace >= 0, `${name} handler should have a body.`);
let depth = 0;
for (let index = firstBrace; index < source.length; index += 1) {
const char = source[index];
if (char === "{") {
depth += 1;
}
if (char === "}") {
depth -= 1;
if (depth === 0) {
return source.slice(start, index + 1);
}
}
}
assert.fail(`${name} handler body should close.`);
}
test("/dashboard/outreach mounts the outreach review workspace", async () => {
const source = await readFile(outreachPagePath, "utf8");
assert.doesNotMatch(source, /DashboardPlaceholderPage/);
assert.match(source, /OutreachReviewWorkspace/);
assert.match(
source,
/@\/components\/outreach\/outreach-review-workspace/,
);
});
test("OutreachReviewWorkspace uses the review workspace API and required controls", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");
assert.match(source, /api\.outreach(?:\s+as\s+OutreachApi\))?\.listReviewWorkspace/);
assert.match(source, /limit:\s*100/);
assert.match(source, /api\.outreach(?:\s+as\s+OutreachApi\))?\.saveReviewDraft/);
assert.match(source, /api\.outreach(?:\s+as\s+OutreachApi\))?\.approveEmailDraft/);
assert.match(source, /api\.audits\.savePublicAuditContent/);
assert.match(source, /api\.audits\.publishPublicAudit/);
[
"Lead-Details",
"Kontaktquellen",
"Prioritätsgrund",
"Kontaktstrategie",
"Audit-Zusammenfassung",
"Public-Audit",
"Verwendete Skills",
"Quellen anzeigen",
"Raw anzeigen",
"E-Mail-Betreff",
"E-Mail-Text",
"Telefon-Skript",
"Follow-up-Draft",
].forEach((label) => assert.match(source, new RegExp(label)));
});
test("OutreachReviewWorkspace keeps exactly one recommended email subject and body editor", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");
assert.equal((source.match(/aria-label="E-Mail-Betreff"/g) ?? []).length, 1);
assert.equal((source.match(/aria-label="E-Mail-Text"/g) ?? []).length, 1);
assert.equal((source.match(/<Input\b/g) ?? []).length, 1);
assert.doesNotMatch(source, /Version\s*[23]|Alternative|Variante/);
});
test("OutreachReviewWorkspace separates audit publication from email approval", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");
assert.match(source, /Audit veröffentlichen/);
assert.match(source, /Änderungen speichern/);
assert.match(source, /E-Mail freigeben/);
assert.doesNotMatch(source, /E-Mail freigeben und senden/);
assert.doesNotMatch(source, /api\.outreach\.(send|sendEmail|sendDraft)/);
const auditPublishIndex = source.indexOf("Audit veröffentlichen");
const auditSaveIndex = source.indexOf("Änderungen speichern");
const emailApprovalIndex = source.indexOf("E-Mail freigeben");
assert.ok(auditPublishIndex >= 0);
assert.ok(auditSaveIndex >= 0);
assert.ok(emailApprovalIndex >= 0);
assert.ok(
Math.abs(auditPublishIndex - auditSaveIndex) <
Math.abs(auditPublishIndex - emailApprovalIndex),
"Audit actions should be grouped closer to each other than to email approval.",
);
});
test("OutreachReviewWorkspace gates phone scripts to call-first or missing-contact leads with phone numbers", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");
assert.match(source, /hasCallablePhone/);
assert.match(source, /strategy\s*===\s*"call_first"/);
assert.match(source, /lead\?\.contactStatus\s*===\s*"missing_contact"/);
assert.match(source, /Kein Telefon-Skript erforderlich/);
});
test("approveEmail saves the visible outreach draft before approving it", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");
const handler = extractConstFunction(source, "approveEmail");
const draftIndex = handler.indexOf("const draft = drafts[record.id] ?? getDraft(record)");
const saveIndex = handler.indexOf("await saveReviewDraft");
const approveIndex = handler.indexOf("await approveEmailDraft");
assert.ok(draftIndex >= 0, "Approval should read the current local draft.");
assert.ok(saveIndex >= 0, "Approval should persist the current draft first.");
assert.ok(approveIndex >= 0, "Approval should still call approveEmailDraft.");
assert.ok(
draftIndex < saveIndex && saveIndex < approveIndex,
"Approval should read draft, save it, then approve.",
);
assert.match(handler, /emailSubject:\s*draft\.emailSubject/);
assert.match(handler, /emailBody:\s*draft\.emailBody/);
assert.match(handler, /followUpDraft:\s*draft\.followUpDraft/);
});
test("publishAudit saves the visible audit draft before publishing it", async () => {
const source = await readFile(outreachWorkspacePath, "utf8");
const handler = extractConstFunction(source, "publishAudit");
const draftIndex = handler.indexOf("const draft = drafts[record.id] ?? getDraft(record)");
const saveIndex = handler.indexOf("await savePublicAuditContent");
const publishIndex = handler.indexOf("await publishPublicAudit");
assert.ok(draftIndex >= 0, "Publishing should read the current local audit draft.");
assert.ok(saveIndex >= 0, "Publishing should save the public audit content first.");
assert.ok(publishIndex >= 0, "Publishing should still call publishPublicAudit.");
assert.ok(
draftIndex < saveIndex && saveIndex < publishIndex,
"Publishing should read draft, save it, then publish.",
);
assert.match(handler, /publicSummary:\s*draft\.auditSummary/);
assert.match(handler, /publicBody:\s*draft\.auditBody/);
});