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
18 changes: 15 additions & 3 deletions app/(app)/projects/[id]/social/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createClient } from "@/lib/supabase/server";
import { PostNowForm } from "./post-now";
import { RetryPostButton } from "./retry-post-button";
import { RetryFeedItemButton } from "./retry-feed-item-button";
import { VerificationCodeInput } from "@/components/verification-code-input";
import { FeedSettingsForm } from "./feed-settings";
import { SocialAutoRefresh } from "./auto-refresh";
import { SocialProfileForm, type SocialProfile } from "./social-profile";
Expand Down Expand Up @@ -79,7 +80,7 @@ export default async function SocialDashboardPage({
supabase
.from("sp_post")
.select(
"id, account_id, rendered_text, source, status, published_at, platform_post_url, last_error, created_at",
"id, account_id, rendered_text, source, status, published_at, platform_post_url, last_error, created_at, verification_prompt",
)
.eq("user_id", user.id)
.eq("project_id", projectId)
Expand Down Expand Up @@ -141,7 +142,10 @@ export default async function SocialDashboardPage({
// Only auto-refresh while something is actively moving. Idle tabs
// shouldn't be hitting the DB every 15s.
const inFlightPosts = (posts ?? []).filter(
(p: any) => p.status === "queued" || p.status === "publishing",
(p: any) =>
p.status === "queued" ||
p.status === "publishing" ||
p.status === "awaiting_code",
);
const hasInFlightPost = inFlightPosts.length > 0;
const feedChecking =
Expand Down Expand Up @@ -484,7 +488,7 @@ export default async function SocialDashboardPage({
: "badge-warn")
}
>
{p.status}
{p.status === "awaiting_code" ? "needs code" : p.status}
</span>
</div>
{url && title && (
Expand Down Expand Up @@ -531,6 +535,14 @@ export default async function SocialDashboardPage({
</>
)}
</div>
{p.status === "awaiting_code" && (
<div className="mt-1 text-xs">
<VerificationCodeInput
postId={p.id}
prompt={p.verification_prompt}
/>
</div>
)}
{p.status === "failed" && (
<div className="mt-1">
{p.last_error && (
Expand Down
18 changes: 16 additions & 2 deletions app/(marketing)/recent/outreach-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@ import {
sendRecentAuditOutreach,
} from "@/app/actions/recent-outreach";
import { OUTREACH_CREDITS } from "@/lib/credits";
import { VerificationCodeInput } from "@/components/verification-code-input";

export type OutreachHistoryItem = {
id: string;
channel: string;
provider: string;
status: "sent" | "failed" | "queued" | "timed_out";
status: "sent" | "failed" | "queued" | "timed_out" | "awaiting_code";
subject: string | null;
error: string | null;
createdAt: string;
url: string | null;
verificationPostId?: string | null;
verificationPrompt?: string | null;
};

export function RecentOutreachForm({
Expand Down Expand Up @@ -132,6 +135,14 @@ export function RecentOutreachForm({
{(h.status === "failed" || h.status === "timed_out") && (
<RetryOutreachButton messageId={h.id} />
)}
{h.status === "awaiting_code" && h.verificationPostId && (
<span className="w-full">
<VerificationCodeInput
postId={h.verificationPostId}
prompt={h.verificationPrompt}
/>
</span>
)}
{(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 @@ -325,13 +336,16 @@ function defaultBody(host: string) {
}

function statusLabel(status: OutreachHistoryItem["status"]) {
return status === "timed_out" ? "timed out" : status;
if (status === "timed_out") return "timed out";
if (status === "awaiting_code") return "needs code";
return status;
}

function statusClass(status: OutreachHistoryItem["status"]) {
const base = "rounded px-1.5 py-0.5 font-medium";
if (status === "sent") return `${base} bg-green-100 text-green-800`;
if (status === "failed") return `${base} bg-red-100 text-red-700`;
if (status === "timed_out") return `${base} bg-amber-100 text-amber-800`;
if (status === "awaiting_code") return `${base} bg-blue-100 text-blue-800`;
return `${base} bg-[var(--color-border)] text-[var(--color-muted)]`;
}
6 changes: 5 additions & 1 deletion app/(marketing)/recent/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ async function fetchOutreachHistory(
const { data } = await supabase
.from("recent_outreach_messages")
.select(
"id, audit_id, channel, provider, status, subject, error, created_at, social_post:sp_post(status, platform_post_url, last_error)",
"id, audit_id, channel, provider, status, subject, error, created_at, social_post:sp_post(id, status, platform_post_url, last_error, verification_prompt)",
)
.eq("organization_id", organizationId)
.in("audit_id", auditIds)
Expand Down Expand Up @@ -354,6 +354,10 @@ async function fetchOutreachHistory(
error: derived.error,
createdAt: row.created_at,
url: post?.platform_post_url ?? null,
verificationPostId:
derived.status === "awaiting_code" ? post?.id ?? null : null,
verificationPrompt:
derived.status === "awaiting_code" ? post?.verification_prompt ?? null : null,
};
const list = byAudit.get(row.audit_id) ?? [];
list.push(item);
Expand Down
47 changes: 47 additions & 0 deletions app/actions/socialPosting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,3 +918,50 @@ export async function retryFeedItem(input: {
if (projectId) revalidatePath(`/projects/${projectId}/social`);
return { ok: true };
}

// ------------------------------------------------------------
// submitVerificationCode — hand a verification code to a browser post that is
// paused on an identity challenge (status 'awaiting_code'). The worker is
// holding the live Chromium session open and polling sp_post.verification_code;
// writing it here lets it type the code and finish posting.
// ------------------------------------------------------------
export async function submitVerificationCode(input: {
postId: string;
code: string;
}): Promise<Ok | Err> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Not authenticated." };

// Accept the common 4–8 digit codes; strip spaces/dashes the user may paste.
const code = input.code.replace(/[\s-]/g, "");
if (!/^\d{4,8}$/.test(code)) {
return { ok: false, error: "Enter the numeric code (4–8 digits)." };
}

const { data: post } = await supabase
.from("sp_post")
.select("id, status, project_id")
.eq("id", input.postId)
.eq("user_id", user.id)
.maybeSingle();
if (!post) return { ok: false, error: "Post not found." };
if ((post as { status?: string }).status !== "awaiting_code") {
return { ok: false, error: "This post isn't waiting for a code right now." };
}

const { error } = await supabase
.from("sp_post")
.update({ verification_code: code })
.eq("id", input.postId)
.eq("user_id", user.id)
.eq("status", "awaiting_code");
if (error) return { ok: false, error: error.message };

const projectId = (post as { project_id?: string | null }).project_id;
if (projectId) revalidatePath(`/projects/${projectId}/social`);
revalidatePath("/recent");
return { ok: true };
}
70 changes: 70 additions & 0 deletions components/verification-code-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use client";

import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { submitVerificationCode } from "@/app/actions/socialPosting";

// Shown on a browser post that's paused on an identity challenge
// (status 'awaiting_code'). The worker is holding the live session open and
// polling for the code; submitting it here lets the post finish.
export function VerificationCodeInput({
postId,
prompt,
}: {
postId: string;
prompt?: string | null;
}) {
const router = useRouter();
const [code, setCode] = useState("");
const [pending, start] = useTransition();
const [err, setErr] = useState<string | null>(null);
const [done, setDone] = useState(false);

if (done) {
return (
<span className="text-[var(--color-muted)]">
Code submitted — finishing the post…
</span>
);
}

return (
<span className="inline-flex flex-wrap items-center gap-2">
{prompt && (
<span className="w-full text-[var(--color-warn)]">{prompt}</span>
)}
<input
value={code}
onChange={(e) => setCode(e.target.value)}
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
className="w-24 rounded border border-[var(--color-border)] bg-[var(--color-card)] px-2 py-0.5 tabular-nums"
/>
<button
type="button"
disabled={pending || !code.trim()}
onClick={() => {
setErr(null);
start(async () => {
const r = await submitVerificationCode({ postId, code });
if (!r.ok) {
setErr(r.error);
return;
}
setDone(true);
router.refresh();
});
}}
className="rounded border border-[var(--color-border)] px-2 py-0.5 font-medium hover:bg-[var(--color-card)] disabled:opacity-50"
>
{pending ? "Submitting…" : "Submit code"}
</button>
{err && (
<span className="w-full text-red-600" title={err}>
{err}
</span>
)}
</span>
);
}
18 changes: 13 additions & 5 deletions lib/sp/browserPost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from "@/lib/sp/imageGen";
import { reconcileOutreach } from "@/lib/sp/outreachReconcile";
import { pickDefaultSubreddit } from "@/lib/sp/redditSubreddit";
import { makeCodeWaiter } from "@/lib/sp/verificationChallenge";

export async function processBrowserPost(args: {
postId: string;
Expand Down Expand Up @@ -108,6 +109,11 @@ export async function processBrowserPost(args: {
return;
}

// When a platform interrupts the session for a verification code, the
// platform flow calls this to pause, surface a prompt, and wait for the code
// the user submits (see makeCodeWaiter / submitVerificationCode).
const waitForCode = makeCodeWaiter(supabase, postId);

try {
let result: { platformPostId: string; webUrl: string };

Expand All @@ -118,29 +124,31 @@ export async function processBrowserPost(args: {
const subreddit =
((claimed.subreddit as string | null) ?? "").trim() ||
pickDefaultSubreddit(title || text);
result = await redditBrowserPost({ cookies, subreddit, title, text });
result = await redditBrowserPost({ cookies, subreddit, title, text, waitForCode });
} else if (account.platform === "facebook_page") {
result = await facebookBrowserPost({
cookies,
pageId: account.external_id,
text,
imageUrl,
waitForCode,
});
} else if (account.platform === "threads") {
result = await threadsBrowserPost({ cookies, text, imageUrl });
result = await threadsBrowserPost({ cookies, text, imageUrl, waitForCode });
} else if (account.platform === "instagram") {
result = await instagramBrowserPost({
cookies,
caption: text,
imageUrl: imageUrl!,
waitForCode,
});
} else if (account.platform === "x") {
result = await xBrowserPost({ cookies, text, imageUrl });
result = await xBrowserPost({ cookies, text, imageUrl, waitForCode });
} else if (account.platform === "linkedin") {
result = await linkedinBrowserPost({ cookies, text, imageUrl });
result = await linkedinBrowserPost({ cookies, text, imageUrl, waitForCode });
} else if (account.platform === "mastodon") {
const instanceUrl = account.instance_url ?? "mastodon.social";
result = await mastodonBrowserPost({ cookies, instanceUrl, text, imageUrl });
result = await mastodonBrowserPost({ cookies, instanceUrl, text, imageUrl, waitForCode });
} else {
throw new Error(`Browser posting not implemented for platform: ${account.platform}`);
}
Expand Down
17 changes: 16 additions & 1 deletion lib/sp/outreachStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@
// never came back.

export type OutreachRowStatus = "sent" | "failed" | "queued";
export type OutreachDisplayStatus = "sent" | "failed" | "queued" | "timed_out";
export type OutreachDisplayStatus =
| "sent"
| "failed"
| "queued"
| "timed_out"
| "awaiting_code";

export type OutreachSocialPost = {
id?: string | null;
status: string | null;
platform_post_url: string | null;
last_error: string | null;
verification_prompt?: string | null;
};

// How long a browser-automated post (auth_mode='cookie') may sit in the
Expand All @@ -37,6 +44,14 @@ export function deriveOutreachStatus(
if (post.status === "failed" || post.status === "cancelled") {
return { status: "failed", error: post.last_error ?? rowError };
}
// Paused on an identity challenge — the user needs to enter a code. Surface
// it before the stale check so it isn't mistaken for a hung job.
if (post.status === "awaiting_code") {
return {
status: "awaiting_code",
error: post.verification_prompt ?? "Waiting for a verification code.",
};
}
// Still queued_browser / publishing — flip to timed out once stale.
if (now - new Date(createdAt).getTime() > OUTREACH_STALE_MS) {
return {
Expand Down
Loading
Loading