Files
sp/docs/n8n/README.md
Semih 0419cc271c 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>
2026-05-24 01:42:11 +03:00

5.2 KiB
Raw Blame History

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>

{
  "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)

{ "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. WebhookPOST, 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.