Add SMTP send flow for approved outreach

This commit is contained in:
Matthias
2026-06-05 21:05:59 +02:00
parent 42a3ea64a5
commit b2f7348ef0
10 changed files with 1531 additions and 56 deletions

View File

@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { useAction, useMutation, useQuery } from "convex/react";
import type { FunctionReturnType } from "convex/server";
import { ChevronDown, ChevronRight, ExternalLink, MailCheck, Save } from "lucide-react";
import Link from "next/link";
@@ -11,6 +11,14 @@ import { api } from "@/convex/_generated/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
@@ -30,6 +38,14 @@ type DraftState = {
phoneScript: string;
};
type PendingEmailConfirmation = {
id: NonNullable<ReviewWorkspaceItem["latestOutreach"]>["_id"];
recipient: string;
subject: string;
sender: string;
auditSlug: string | null;
};
const emptyDraft: DraftState = {
auditBody: "",
auditSummary: "",
@@ -60,6 +76,30 @@ function compactText(value?: string | null, fallback = "Offen") {
return trimmed ? trimmed : fallback;
}
function toStringIfText(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function toNullableString(value: unknown) {
const text = toStringIfText(value);
return text.length > 0 ? text : null;
}
function asRecord(value: unknown) {
return (typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {});
}
function extractRecordValue(record: Record<string, unknown>, candidates: string[]) {
for (const candidate of candidates) {
const text = toStringIfText(record[candidate]);
if (text) {
return text;
}
}
return "";
}
function formatStrategy(strategy?: string | null) {
const labels: Record<string, string> = {
call_first: "Erst anrufen",
@@ -140,14 +180,17 @@ export function OutreachReviewWorkspace() {
const approveEmailDraft = useMutation(api.outreach.approveEmailDraft);
const savePublicAuditContent = useMutation(api.audits.savePublicAuditContent);
const publishPublicAudit = useMutation(api.audits.publishPublicAudit);
const sendApprovedEmail = useAction(api.outreachSendAction.sendApprovedEmail);
const [drafts, setDrafts] = useState<Record<string, DraftState>>({});
const [openSources, setOpenSources] = useState<Record<string, boolean>>({});
const [openRaw, setOpenRaw] = useState<Record<string, boolean>>({});
const [busyAction, setBusyAction] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [pendingEmailConfirmation, setPendingEmailConfirmation] =
useState<PendingEmailConfirmation | null>(null);
const rows = useMemo(() => records ?? [], [records]);
const rows = useMemo<ReviewWorkspaceItem[]>(() => records ?? [], [records]);
if (records === undefined) {
return <WorkspaceLoading />;
@@ -243,6 +286,12 @@ export function OutreachReviewWorkspace() {
setNotice("Outreach-Entwurf kann ohne Outreach-ID nicht gespeichert werden.");
return;
}
if (outreach.sendStatus === "queued") {
setNotice(
"Aufgrund des laufenden Sendevorgangs kann der Outreach-Entwurf nicht gespeichert werden.",
);
return;
}
const draft = drafts[record.id] ?? getDraft(record);
const strategy = outreach.strategy;
@@ -270,12 +319,57 @@ export function OutreachReviewWorkspace() {
}
};
const closeEmailConfirmation = () => {
setPendingEmailConfirmation(null);
};
const sendApprovedEmailFromConfirmation = async () => {
const confirmation = pendingEmailConfirmation;
if (!confirmation) {
return;
}
const isQueuedSend = rows.some(
(row: ReviewWorkspaceItem) =>
row.latestOutreach?._id === confirmation.id &&
row.latestOutreach?.sendStatus === "queued",
);
if (isQueuedSend) {
setNotice(
"Aufgrund des laufenden Sendevorgangs kann der Versand nicht erneut gestartet werden.",
);
return;
}
setBusyAction(`${confirmation.id}:email-send`);
setNotice(null);
try {
await sendApprovedEmail({ id: confirmation.id });
setNotice("E-Mail gesendet.");
setPendingEmailConfirmation(null);
} catch {
setNotice(
"E-Mail konnte nicht gesendet werden. Bitte erneut versuchen.",
);
} finally {
setBusyAction(null);
}
};
const approveEmail = async (record: ReviewWorkspaceItem) => {
const outreach = record.latestOutreach;
const lead = record.lead;
const audit = record.audit;
if (!outreach) {
setNotice("E-Mail kann ohne Outreach-ID nicht freigegeben werden.");
return;
}
if (outreach.sendStatus === "queued") {
setNotice(
"Aufgrund des laufenden Sendevorgangs kann die E-Mail nicht freigegeben werden.",
);
return;
}
const draft = drafts[record.id] ?? getDraft(record);
const strategy = outreach.strategy;
@@ -295,7 +389,34 @@ export function OutreachReviewWorkspace() {
followUpDraft: draft.followUpDraft,
...(hasCallablePhone ? { phoneScript: draft.phoneScript } : {}),
});
await approveEmailDraft({ id: outreach._id });
const approvalResult = await approveEmailDraft({ id: outreach._id });
const approvalData = asRecord(approvalResult);
const recipient =
extractRecordValue(approvalData, ["recipient", "email", "emailAddress"]) ||
lead?.email ||
"Offen";
const subject =
extractRecordValue(approvalData, [
"emailSubject",
"subject",
"title",
]) || draft.emailSubject;
const sender = toNullableString(approvalData.sender);
if (!sender) {
throw new Error("SMTP-Absender in der Freigabeantwort fehlt.");
}
const auditSlug =
extractRecordValue(approvalData, ["auditSlug", "slug", "audit"]) ||
audit?.slug ||
null;
setPendingEmailConfirmation({
id: outreach._id,
recipient,
subject,
sender,
auditSlug,
});
setNotice("E-Mail freigegeben.");
} catch {
setNotice("E-Mail konnte nicht freigegeben werden.");
@@ -304,6 +425,16 @@ export function OutreachReviewWorkspace() {
}
};
const confirmationAuditLink = pendingEmailConfirmation?.auditSlug
? `/audit/${pendingEmailConfirmation.auditSlug}`
: null;
const pendingQueuedOutreachId = pendingEmailConfirmation?.id;
const isQueuedSendForConfirmation = rows.some(
(row: ReviewWorkspaceItem) =>
row.latestOutreach?._id === pendingQueuedOutreachId &&
row.latestOutreach?.sendStatus === "queued",
);
return (
<section className="space-y-4">
<header className="space-y-2">
@@ -319,12 +450,88 @@ export function OutreachReviewWorkspace() {
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm">{notice}</p>
) : null}
<Dialog
open={Boolean(pendingEmailConfirmation)}
onOpenChange={(open) => {
if (!open) {
closeEmailConfirmation();
}
}}
>
{pendingEmailConfirmation ? (
<DialogContent className="space-y-4">
<DialogHeader>
<DialogTitle>E-Mail-Versand bestätigen</DialogTitle>
<DialogDescription>
Bitte prüfen Sie vor dem Senden die Finaldaten.
</DialogDescription>
<DialogCloseButton />
</DialogHeader>
<div className="space-y-3">
<div className="min-w-0">
<label className="text-xs font-medium text-muted-foreground">
Empfänger
</label>
<p className="break-words text-sm">{pendingEmailConfirmation.recipient}</p>
</div>
<div className="min-w-0">
<label className="text-xs font-medium text-muted-foreground">
Betreff
</label>
<p className="break-words text-sm">{pendingEmailConfirmation.subject}</p>
</div>
<div className="min-w-0">
<label className="text-xs font-medium text-muted-foreground">
Absender
</label>
<p className="text-sm">
{pendingEmailConfirmation.sender}
</p>
</div>
<div className="min-w-0">
<label className="text-xs font-medium text-muted-foreground">
Audit-Link
</label>
{confirmationAuditLink ? (
<Link
className="inline-flex items-center gap-1 break-all text-sm text-blue-600 hover:underline"
href={confirmationAuditLink}
>
<ExternalLink className="size-3.5" />
{confirmationAuditLink}
</Link>
) : (
<p className="text-sm text-muted-foreground">Nicht verfügbar.</p>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
disabled={
busyAction === `${pendingEmailConfirmation.id}:email-send` ||
isQueuedSendForConfirmation
}
onClick={sendApprovedEmailFromConfirmation}
size="sm"
type="button"
>
Senden
</Button>
<Button onClick={closeEmailConfirmation} size="sm" type="button" variant="outline">
Abbrechen
</Button>
</div>
</DialogContent>
) : null}
</Dialog>
<div className="space-y-3">
{rows.map((record) => {
const draft = drafts[record.id] ?? getDraft(record);
const lead = record.lead;
const audit = record.audit;
const outreach = record.latestOutreach;
const isQueuedSend = outreach?.sendStatus === "queued";
const strategy = outreach?.strategy;
const contactSources = [
lead.email ? `E-Mail: ${lead.email}` : null,
@@ -506,7 +713,9 @@ export function OutreachReviewWorkspace() {
</label>
<div className="flex flex-wrap gap-2">
<Button
disabled={busyAction === `${record.id}:outreach-save`}
disabled={
busyAction === `${record.id}:outreach-save` || isQueuedSend
}
onClick={() => saveOutreach(record)}
size="sm"
type="button"
@@ -516,13 +725,15 @@ export function OutreachReviewWorkspace() {
Änderungen speichern
</Button>
<Button
disabled={busyAction === `${record.id}:email-approval`}
disabled={
busyAction === `${record.id}:email-approval` || isQueuedSend
}
onClick={() => approveEmail(record)}
size="sm"
type="button"
>
<MailCheck className="size-3.5" />
E-Mail freigeben
E-Mail freigeben und senden
</Button>
</div>
</div>