feat(content): Phase 8c publish layer — panel→n8n webhook (sync) + LinkedIn/blog
Approved drafts publish via a single n8n `publish-content` webhook; the panel POSTs the effective content (founder edits merged over generated body) and awaits a synchronous Respond-to-Webhook result (sp.semih.ai is Tailscale-only, so we avoid an n8n→panel callback). n8n routes by channel. - lib/n8n.ts: publishToN8n client (X-Content-Secret header, timeout, tolerant result parsing: ok|success + publishedUrl|url|postUrl|permalink) - publishDraft server action: approved|failed → publishing → published(+url) / failed(+error), audit-logged; effective content = bodyJson + founderEdits - DraftCard: "Yayınla" / "Yeniden yayınla" button + publishing state - docs/n8n: importable publish-content workflow (Webhook → Switch → LinkedIn / HTTP-blog → Respond) + runbook (contract, panel envs, LinkedIn OAuth setup, blog endpoint = sase.tr POST /blog/posts/internal Bearer) Needs panel-web envs N8N_PUBLISH_WEBHOOK_URL + N8N_WEBHOOK_SECRET. Publish is a graceful no-op (clear error) until those are set and the n8n workflow exists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { contentQueue } from "@/lib/queue";
|
||||
import { publishToN8n } from "@/lib/n8n";
|
||||
|
||||
const PROJECT_KEY = "sase";
|
||||
const VALID_CHANNELS = ["blog", "linkedin", "x", "instagram"];
|
||||
@@ -149,3 +150,65 @@ export async function setDraftStatus(draftId: string, status: string) {
|
||||
revalidatePath(`/content/t/${draft.topicId}`);
|
||||
revalidatePath("/content");
|
||||
}
|
||||
|
||||
// Publish an approved (or previously-failed) draft via the n8n publish webhook.
|
||||
// Synchronous: we await n8n's response and persist the final status here.
|
||||
export async function publishDraft(draftId: string) {
|
||||
await requireSession();
|
||||
const draft = await prisma.contentDraft.findUnique({
|
||||
where: { id: draftId },
|
||||
include: { topic: { select: { title: true } } },
|
||||
});
|
||||
if (!draft) throw new Error("draft not found");
|
||||
if (!["approved", "failed"].includes(draft.status)) {
|
||||
throw new Error("only approved (or failed) drafts can be published");
|
||||
}
|
||||
|
||||
// Effective content = generated body with founder edits layered on top.
|
||||
const base = (draft.bodyJson && typeof draft.bodyJson === "object" && !Array.isArray(draft.bodyJson)
|
||||
? (draft.bodyJson as Record<string, unknown>)
|
||||
: {});
|
||||
const edits = (draft.founderEdits && typeof draft.founderEdits === "object" && !Array.isArray(draft.founderEdits)
|
||||
? (draft.founderEdits as Record<string, unknown>)
|
||||
: {});
|
||||
const content = { ...base, ...edits };
|
||||
|
||||
await prisma.contentDraft.update({
|
||||
where: { id: draftId },
|
||||
data: { status: "publishing", publishError: null },
|
||||
});
|
||||
revalidatePath(`/content/t/${draft.topicId}`);
|
||||
|
||||
const result = await publishToN8n({
|
||||
draftId,
|
||||
channel: draft.channel,
|
||||
projectKey: PROJECT_KEY,
|
||||
topicTitle: draft.topic.title,
|
||||
content,
|
||||
});
|
||||
|
||||
await prisma.contentDraft.update({
|
||||
where: { id: draftId },
|
||||
data: result.ok
|
||||
? {
|
||||
status: "published",
|
||||
publishedUrl: result.publishedUrl ?? null,
|
||||
publishedAt: new Date(),
|
||||
publishError: null,
|
||||
}
|
||||
: { status: "failed", publishError: (result.error ?? "unknown").slice(0, 500) },
|
||||
});
|
||||
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: `/content/drafts/${draftId}/publish`,
|
||||
method: "POST",
|
||||
requestPayload: { channel: draft.channel, ok: result.ok },
|
||||
responseStatus: result.ok ? 200 : 502,
|
||||
});
|
||||
revalidatePath(`/content/t/${draft.topicId}`);
|
||||
revalidatePath("/content");
|
||||
|
||||
if (!result.ok) throw new Error(result.error ?? "publish failed");
|
||||
return result.publishedUrl ?? null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { saveDraftEdits, setDraftStatus } from "../../_actions";
|
||||
import { saveDraftEdits, setDraftStatus, publishDraft } from "../../_actions";
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
@@ -183,9 +183,27 @@ export function DraftCard(props: Props) {
|
||||
>
|
||||
Reddet
|
||||
</Button>
|
||||
{(props.status === "approved" || props.status === "failed") && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
fire(
|
||||
() => publishDraft(props.draftId),
|
||||
props.status === "failed" ? "yeniden yayınlanıyor" : "yayınlanıyor",
|
||||
)
|
||||
}
|
||||
>
|
||||
{props.status === "failed" ? "Yeniden yayınla" : "Yayınla"}
|
||||
</Button>
|
||||
)}
|
||||
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.status === "publishing" && (
|
||||
<div className="border-t pt-2 text-xs text-muted-foreground">n8n'e gönderildi, yayınlanıyor…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
68
apps/web/src/lib/n8n.ts
Normal file
68
apps/web/src/lib/n8n.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// n8n publish client (Phase 8c). The panel POSTs an approved draft to the
|
||||
// n8n `publish-content` webhook and waits for a synchronous response (n8n's
|
||||
// "Respond to Webhook" node returns the publish result). Synchronous by
|
||||
// design: sp.semih.ai is Tailscale-only, so an n8n→panel callback would need
|
||||
// cross-network reachability we'd rather avoid. n8n routes by `channel`.
|
||||
|
||||
const WEBHOOK_URL = process.env.N8N_PUBLISH_WEBHOOK_URL ?? "";
|
||||
const SECRET = process.env.N8N_WEBHOOK_SECRET ?? "";
|
||||
const TIMEOUT_MS = Number(process.env.N8N_PUBLISH_TIMEOUT_MS ?? "30000");
|
||||
|
||||
export type PublishRequest = {
|
||||
draftId: string;
|
||||
channel: string;
|
||||
projectKey: string;
|
||||
topicTitle: string;
|
||||
content: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PublishResult = {
|
||||
ok: boolean;
|
||||
publishedUrl?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function n8nConfigured(): boolean {
|
||||
return Boolean(WEBHOOK_URL);
|
||||
}
|
||||
|
||||
export async function publishToN8n(req: PublishRequest): Promise<PublishResult> {
|
||||
if (!WEBHOOK_URL) return { ok: false, error: "N8N_PUBLISH_WEBHOOK_URL not set" };
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(SECRET ? { "x-content-secret": SECRET } : {}),
|
||||
},
|
||||
body: JSON.stringify(req),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: `n8n unreachable: ${(e as Error).message}` };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) return { ok: false, error: `n8n ${res.status}: ${text.slice(0, 200)}` };
|
||||
|
||||
// n8n may wrap the Respond-to-Webhook body or return it bare.
|
||||
try {
|
||||
const parsed = JSON.parse(text || "{}");
|
||||
const ok = parsed.ok === true || parsed.success === true;
|
||||
const publishedUrl =
|
||||
parsed.publishedUrl ?? parsed.url ?? parsed.postUrl ?? parsed.permalink ?? undefined;
|
||||
if (!ok && !publishedUrl) {
|
||||
return { ok: false, error: String(parsed.error ?? parsed.message ?? text.slice(0, 200)) };
|
||||
}
|
||||
return { ok: true, publishedUrl };
|
||||
} catch {
|
||||
// Non-JSON 2xx — treat as success but without a URL.
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user