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:
Semih
2026-05-24 01:42:11 +03:00
parent fdce3f6bd0
commit 0419cc271c
5 changed files with 423 additions and 1 deletions

View File

@@ -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;
}

View File

@@ -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
View 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 };
}
}