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 };
|
||||
}
|
||||
}
|
||||
113
docs/n8n/README.md
Normal file
113
docs/n8n/README.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Content publish — n8n `publish-content` workflow (Phase 8c)
|
||||
|
||||
The panel generates + reviews drafts; **n8n publishes them**. The panel POSTs an
|
||||
approved draft to a single n8n webhook and waits for a synchronous response.
|
||||
n8n routes by `channel` (LinkedIn = native node, blog = HTTP to sase.tr API).
|
||||
|
||||
Synchronous by design: `sp.semih.ai` is Tailscale-only, so we avoid an
|
||||
n8n→panel callback. n8n returns the result via a **Respond to Webhook** node.
|
||||
|
||||
## Contract
|
||||
|
||||
**Panel → n8n** (`POST` to `N8N_PUBLISH_WEBHOOK_URL`)
|
||||
|
||||
Headers: `Content-Type: application/json`, `X-Content-Secret: <N8N_WEBHOOK_SECRET>`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"draftId": "clx…",
|
||||
"channel": "linkedin", // linkedin | blog | x | instagram
|
||||
"projectKey": "sase",
|
||||
"topicTitle": "Şase Numarası (VIN) Nedir?",
|
||||
"content": { // effective body (founder edits merged over generated)
|
||||
// linkedin: { body, hashtags[], cta? }
|
||||
// blog: { title, slug, meta_description, body_markdown, tags[], cta? }
|
||||
// x: { tweets[], hashtags[] }
|
||||
// instagram:{ caption, hashtags[], image_prompt? }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**n8n → panel** (Respond to Webhook, synchronous)
|
||||
|
||||
```jsonc
|
||||
{ "ok": true, "publishedUrl": "https://www.linkedin.com/feed/update/urn:li:share:123" }
|
||||
// or
|
||||
{ "ok": false, "error": "linkedin 401: token expired" }
|
||||
```
|
||||
|
||||
The panel accepts `ok|success` true, and reads the URL from any of
|
||||
`publishedUrl|url|postUrl|permalink`. A 2xx with no JSON is treated as success
|
||||
without a URL.
|
||||
|
||||
## Panel env (set on panel-web in Coolify)
|
||||
|
||||
| key | value |
|
||||
|-----|-------|
|
||||
| `N8N_PUBLISH_WEBHOOK_URL` | `https://n8n.semih.ai/webhook/publish-content` (prod) — use `/webhook-test/publish-content` while building |
|
||||
| `N8N_WEBHOOK_SECRET` | a long random string; also set as the n8n Header Auth credential value |
|
||||
| `N8N_PUBLISH_TIMEOUT_MS` | optional, default `30000` |
|
||||
|
||||
After setting these, redeploy panel-web (push to main auto-deploys web).
|
||||
|
||||
## Build the workflow in n8n
|
||||
|
||||
Import `publish-content.workflow.json` (Workflows → Import from File) **or**
|
||||
build these 5 nodes:
|
||||
|
||||
1. **Webhook** — `POST`, path `publish-content`, **Respond** = "Using Respond
|
||||
to Webhook node". Authentication = **Header Auth** → credential checking
|
||||
header `X-Content-Secret` equals `N8N_WEBHOOK_SECRET`.
|
||||
2. **Switch** (on `={{ $json.body.channel }}`): route `linkedin` and `blog`
|
||||
(add `x` / `instagram` later). Add a fallback output → error response.
|
||||
3. **LinkedIn** node (`linkedin` branch) — see OAuth setup below. Post text:
|
||||
`={{ $json.body.content.body }}{{ $json.body.content.hashtags ? '\n\n' + $json.body.content.hashtags.join(' ') : '' }}`
|
||||
4. **HTTP Request** (`blog` branch) — `POST {SASE_BLOG_API}/blog/posts/internal`,
|
||||
Header Auth **`Authorization: Bearer <BLOG_AUTOMATION_TOKEN>`** (mirrors
|
||||
sase.tr's existing `changelog/internal` automation pattern), JSON body =
|
||||
`={{ $json.body.content }}` plus `{ "projectKey": "sase" }`.
|
||||
5. **Respond to Webhook** (one per branch, or a shared Set→Respond) — return
|
||||
`{ "ok": true, "publishedUrl": "<from node response>" }`. On the fallback /
|
||||
error path return `{ "ok": false, "error": "<message>" }`.
|
||||
|
||||
Map `publishedUrl` from each node's response:
|
||||
- LinkedIn node returns the share/ugcPost id → build `https://www.linkedin.com/feed/update/<urn>`.
|
||||
- Blog HTTP returns `{ url }` from the sase.tr API (see below).
|
||||
|
||||
Activate the workflow (toggle top-right) to use the `/webhook/` (prod) path.
|
||||
|
||||
## LinkedIn OAuth (n8n credential)
|
||||
|
||||
1. **LinkedIn Developer** (https://www.linkedin.com/developers/) → Create app,
|
||||
associate it with the **company page** you post from.
|
||||
2. Products: request **"Share on LinkedIn"** and **"Advertising API"** /
|
||||
**"Community Management API"** as needed for organization posting. Member
|
||||
posting uses `w_member_social`; company-page posting uses
|
||||
`w_organization_social` (needs page admin + may need app review).
|
||||
3. Auth tab → add redirect URL: `https://n8n.semih.ai/rest/oauth2-credential/callback`.
|
||||
4. In n8n → Credentials → **LinkedIn OAuth2 API** → paste Client ID/Secret,
|
||||
set scopes (`w_member_social` and/or `w_organization_social r_organization_social`),
|
||||
connect, authorize.
|
||||
5. In the LinkedIn node pick "Post" and, for a company page, set
|
||||
`Post As = Organization` + the organization URN.
|
||||
|
||||
> Note: organization posting often requires LinkedIn app review. Start with
|
||||
> member posting (`w_member_social`) to validate end-to-end, then upgrade.
|
||||
|
||||
## Blog (sase.tr API) — see the sase.tr repo
|
||||
|
||||
The `blog` branch POSTs to the new sase.tr blog API
|
||||
(`POST /blog/posts/internal`, header `Authorization: Bearer <BLOG_AUTOMATION_TOKEN>`
|
||||
— mirrors sase.tr's existing `changelog/internal` automation auth). The
|
||||
Drizzle model/endpoint live in the sase.tr codebase (`apps/api/src/blog`,
|
||||
modeled on the `changelog` module). The API returns
|
||||
`{ url: "https://sase.tr/blog/<slug>" }`, which n8n echoes back as
|
||||
`publishedUrl`.
|
||||
|
||||
## Testing end-to-end
|
||||
|
||||
1. Set the panel envs to the **test** webhook URL, click "Listen for test event"
|
||||
in the n8n Webhook node.
|
||||
2. In the panel: approve a draft → **Yayınla**. Watch n8n execute; the panel
|
||||
draft flips to `published` (with URL) or `failed` (with the error).
|
||||
3. Switch the env to the prod `/webhook/` URL and **activate** the workflow.
|
||||
160
docs/n8n/publish-content.workflow.json
Normal file
160
docs/n8n/publish-content.workflow.json
Normal file
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"name": "publish-content",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "publish-content",
|
||||
"responseMode": "responseNode",
|
||||
"authentication": "headerAuth",
|
||||
"options": {}
|
||||
},
|
||||
"id": "webhook-in",
|
||||
"name": "Webhook (publish-content)",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [-200, 300],
|
||||
"webhookId": "publish-content",
|
||||
"credentials": {
|
||||
"httpHeaderAuth": { "id": "REPLACE", "name": "X-Content-Secret" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"rules": {
|
||||
"values": [
|
||||
{
|
||||
"conditions": {
|
||||
"options": { "caseSensitive": true, "typeValidation": "strict" },
|
||||
"conditions": [
|
||||
{
|
||||
"id": "r-linkedin",
|
||||
"leftValue": "={{ $json.body.channel }}",
|
||||
"rightValue": "linkedin",
|
||||
"operator": { "type": "string", "operation": "equals" }
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"renameOutput": true,
|
||||
"outputKey": "linkedin"
|
||||
},
|
||||
{
|
||||
"conditions": {
|
||||
"options": { "caseSensitive": true, "typeValidation": "strict" },
|
||||
"conditions": [
|
||||
{
|
||||
"id": "r-blog",
|
||||
"leftValue": "={{ $json.body.channel }}",
|
||||
"rightValue": "blog",
|
||||
"operator": { "type": "string", "operation": "equals" }
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"renameOutput": true,
|
||||
"outputKey": "blog"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": { "fallbackOutput": "extra" }
|
||||
},
|
||||
"id": "switch-channel",
|
||||
"name": "Switch by channel",
|
||||
"type": "n8n-nodes-base.switch",
|
||||
"typeVersion": 3,
|
||||
"position": [40, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"postAs": "person",
|
||||
"text": "={{ $('Webhook (publish-content)').item.json.body.content.body }}{{ $('Webhook (publish-content)').item.json.body.content.hashtags ? '\\n\\n' + $('Webhook (publish-content)').item.json.body.content.hashtags.join(' ') : '' }}",
|
||||
"additionalFields": {}
|
||||
},
|
||||
"id": "linkedin-post",
|
||||
"name": "LinkedIn — create post",
|
||||
"type": "n8n-nodes-base.linkedIn",
|
||||
"typeVersion": 1,
|
||||
"position": [300, 180],
|
||||
"credentials": {
|
||||
"linkedInOAuth2Api": { "id": "REPLACE", "name": "LinkedIn OAuth2" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{ $env.SASE_BLOG_API }}/blog/posts/internal",
|
||||
"authentication": "genericCredentialType",
|
||||
"genericAuthType": "httpHeaderAuth",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ { ...$('Webhook (publish-content)').item.json.body.content, projectKey: 'sase' } }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "blog-post",
|
||||
"name": "Blog — POST sase.tr",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [300, 360],
|
||||
"credentials": {
|
||||
"httpHeaderAuth": { "id": "REPLACE", "name": "SASE_BLOG_TOKEN (Bearer)" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ { ok: true, publishedUrl: ($json.permalink || $json.urn || $json.id || '') } }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "respond-linkedin",
|
||||
"name": "Respond — LinkedIn",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1,
|
||||
"position": [560, 180]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ { ok: true, publishedUrl: ($json.url || $json.permalink || '') } }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "respond-blog",
|
||||
"name": "Respond — Blog",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1,
|
||||
"position": [560, 360]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ { ok: false, error: 'unsupported channel: ' + $('Webhook (publish-content)').item.json.body.channel } }}",
|
||||
"options": { "responseCode": 422 }
|
||||
},
|
||||
"id": "respond-fallback",
|
||||
"name": "Respond — Unsupported",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1,
|
||||
"position": [300, 540]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook (publish-content)": {
|
||||
"main": [[{ "node": "Switch by channel", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Switch by channel": {
|
||||
"main": [
|
||||
[{ "node": "LinkedIn — create post", "type": "main", "index": 0 }],
|
||||
[{ "node": "Blog — POST sase.tr", "type": "main", "index": 0 }],
|
||||
[{ "node": "Respond — Unsupported", "type": "main", "index": 0 }]
|
||||
]
|
||||
},
|
||||
"LinkedIn — create post": {
|
||||
"main": [[{ "node": "Respond — LinkedIn", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Blog — POST sase.tr": {
|
||||
"main": [[{ "node": "Respond — Blog", "type": "main", "index": 0 }]]
|
||||
}
|
||||
},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"active": false
|
||||
}
|
||||
Reference in New Issue
Block a user