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

114 lines
5.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.