Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion app/(marketing)/recent/outreach-form.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -125,6 +129,9 @@ export function RecentOutreachForm({
view post ↗
</a>
)}
{(h.status === "failed" || h.status === "timed_out") && (
<RetryOutreachButton messageId={h.id} />
)}
{(h.status === "failed" || h.status === "timed_out") && h.error && (
<span className="w-full truncate text-red-600" title={h.error}>
{h.error}
Expand Down Expand Up @@ -279,6 +286,40 @@ export function RecentOutreachForm({
);
}

function RetryOutreachButton({ messageId }: { messageId: string }) {
const router = useRouter();
const [pending, start] = useTransition();
const [err, setErr] = useState<string | null>(null);

return (
<span className="inline-flex items-center gap-2">
<button
type="button"
disabled={pending}
onClick={() => {
setErr(null);
start(async () => {
const r = await retryRecentOutreach({ messageId });
if (!r.ok) {
setErr(r.error);
return;
}
router.refresh();
});
}}
className="rounded border border-[var(--color-border)] px-1.5 py-0.5 font-medium hover:bg-[var(--color-card)] disabled:opacity-50"
>
{pending ? "Retrying…" : "Retry"}
</button>
{err && (
<span className="w-full truncate text-red-600" title={err}>
{err}
</span>
)}
</span>
);
}

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.`;
}
Expand Down
64 changes: 64 additions & 0 deletions app/actions/recent-outreach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ok | Err> {
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<typeof serviceClient>,
organizationId: string,
Expand Down
Loading