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>
114 lines
5.2 KiB
Markdown
114 lines
5.2 KiB
Markdown
# 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.
|