diff --git a/app/(marketing)/recent/outreach-form.tsx b/app/(marketing)/recent/outreach-form.tsx index c1475b02..4c2c70d0 100644 --- a/app/(marketing)/recent/outreach-form.tsx +++ b/app/(marketing)/recent/outreach-form.tsx @@ -1,8 +1,12 @@ "use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { FormEvent, useMemo, useState, useTransition } from "react"; -import { sendRecentAuditOutreach } from "@/app/actions/recent-outreach"; +import { + retryRecentOutreach, + sendRecentAuditOutreach, +} from "@/app/actions/recent-outreach"; import { OUTREACH_CREDITS } from "@/lib/credits"; export type OutreachHistoryItem = { @@ -125,6 +129,9 @@ export function RecentOutreachForm({ view post ↗ )} + {(h.status === "failed" || h.status === "timed_out") && ( + + )} {(h.status === "failed" || h.status === "timed_out") && h.error && ( {h.error} @@ -279,6 +286,40 @@ export function RecentOutreachForm({ ); } +function RetryOutreachButton({ messageId }: { messageId: string }) { + const router = useRouter(); + const [pending, start] = useTransition(); + const [err, setErr] = useState(null); + + return ( + + + {err && ( + + {err} + + )} + + ); +} + function defaultBody(host: string) { return `I saw your CrawlProof audit for ${host}. There are a few concrete fixes that would improve how AI engines understand the site.`; } diff --git a/app/actions/recent-outreach.ts b/app/actions/recent-outreach.ts index 30b46977..67201438 100644 --- a/app/actions/recent-outreach.ts +++ b/app/actions/recent-outreach.ts @@ -236,6 +236,70 @@ export async function sendRecentAuditOutreach(input: { return { ok: true, provider: result.provider }; } +// Re-attempt a previously failed (or timed-out) outreach message. We don't +// mutate the old row — we reconstruct the original request from it and run it +// back through sendRecentAuditOutreach, which re-charges credits, re-records a +// fresh history entry, and refunds on failure exactly like a first attempt. +// Retrying one row at a time also avoids the parallel browser-launch storm that +// makes bulk "post to all" runs fail on the Chromium-based platforms. +export async function retryRecentOutreach(input: { + messageId: string; +}): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Not authenticated." }; + + const svc = serviceClient(); + const { data: msg } = await svc + .from("recent_outreach_messages") + .select( + "id, organization_id, audit_id, channel, provider, subject, body, status, visibility", + ) + .eq("id", input.messageId) + .maybeSingle(); + if (!msg) return { ok: false, error: "Outreach message not found." }; + + const access = await orgOwner(svc, msg.organization_id, user.id); + if (!access.ok) return { ok: false, error: access.error }; + + if (msg.status !== "failed" && msg.status !== "timed_out") { + return { ok: false, error: "Only failed outreach can be retried." }; + } + + const channel = msg.channel as OutreachChannel; + + // Social rows only record the platform (provider), not the specific account, + // so resolve the user's active account(s) on that platform to retry against. + let socialAccountIds: string[] = []; + if (channel === "social") { + const { data: accts } = await svc + .from("sp_account") + .select("id") + .eq("user_id", user.id) + .eq("status", "active") + .eq("platform", msg.provider); + socialAccountIds = ((accts ?? []) as Array<{ id: string }>).map((a) => a.id); + if (socialAccountIds.length === 0) { + return { + ok: false, + error: `No active ${msg.provider} account to retry with — reconnect it first.`, + }; + } + } + + return sendRecentAuditOutreach({ + auditId: msg.audit_id, + organizationId: msg.organization_id, + channel, + visibility: (msg.visibility as OutreachVisibility) ?? "private", + socialAccountIds, + subject: msg.subject ?? undefined, + body: msg.body, + }); +} + async function loadOutreachConfig( svc: ReturnType, organizationId: string,