Post Distribution Dashboard

Track every blog post's distribution across X, LinkedIn, Threads, and newsletter — and turn empty cells into one-click drafts with cross-site account selection and AI generation.

Every blog post lives at the top of a distribution funnel: X thread, LinkedIn post, Meta Threads post, newsletter send. The Distribution Dashboard tracks the state of each surface per post, so a post never silently goes un-amplified after publish — and the path from "published" to "fully distributed" is one click per cell.

What you get

A single page that answers "where is this post?"/admin/distribution shows recent posts × 4 platform columns with status badges per cell (none / draft / scheduled / published). Worst-of-status priority + count badges when a post has multiple artifacts on the same platform.

A per-post deep view/admin/posts/[id]/distribution lists every artifact tied to the post, grouped by platform, with excerpts, schedule/publish timestamps, edit links, and external-URL links for what's already live. Multiple drafts on the same platform stack visibly.

The dashboard is one-click actionable — empty cells render as "+ Draft" CTAs. Click any empty cell and a modal opens with:

  • Mode — blank draft (all four platforms) or "Generate with AI" (X today; other platforms route to the editor where their built-in AI tools live)
  • Cross-site account picker — every connected X / LinkedIn / Threads account the operator owns, across every site they belong to, in one list. A (cross-site) tag flags accounts attached to a different site than the post's

It's wired into the rest of the admin/admin/posts has a distribution-indicator column with four color-coded dots per row (emerald / amber / sky / muted). Hover for tooltips; click for the per-post deep view. Sidebar nav entry "Distribution" lives under "Posts".

Existing artifacts get retroactively linkedPOST /api/admin/distribution/backfill runs a confidence-scored heuristic (URL with site domain → 95, /blog/{slug} path → 90, all slug segments present → 70, title-substring + date proximity → 75, date-only → 30). Dry-run by default; commit with dry_run: false.

How it works

Schema

The linkage is purely additive — no breaking changes to existing artifact tables:

  • twitter_threads.post_id — nullable FK to posts(id)
  • linkedin_posts.post_id — nullable FK
  • threads_posts.post_id — nullable FK
  • newsletters.featured_post_id — nullable FK (the 1:1 case)
  • newsletter_posts — new join table for digest newsletters that feature multiple posts; site_id integrity is trigger-enforced

Cross-site amplification

Three new pointer columns on the social tables — posted_via_account_id + posted_via_site_id — identify which connected account on which site actually published an artifact. The artifact row itself stays under the originating post's site so RLS isolation is unchanged. The pointers are informational, not a tenant move.

Practical consequence: a post on blackopscenter.com can be quote-tweeted from @bennewton999 (an account attached to benenewton.com) in one click. The tweet lives under blackopscenter.com; the picker remembers it fired from the cross-site account.

Status semantics

StatusMeaning
noneNo artifact links to this post on this platform
draftAt least one artifact exists; none scheduled or published
scheduledAt least one artifact has a future scheduled_for
publishedAt least one artifact has been published / posted / sent

Roll-up uses worst-of-status-first: published > scheduled > draft > none. A post with one published thread and one draft tweet shows published ×2 in the X cell.

Admin API

EndpointPurpose
GET /api/admin/distributionIndex — recent posts × 4 platforms with summaries
GET /api/admin/distribution/summary?post_ids=...Lean batched summary (powers the posts-list dots)
GET /api/admin/posts/[id]/distributionPer-post detail with full artifact metadata
POST /api/admin/posts/[id]/distribution/draftCreate a blank draft pre-linked to the post
POST /api/admin/distribution/backfillHeuristic backfill of unlinked artifacts
PATCH /api/admin/distribution/linkManual artifact-to-post link override
GET /api/admin/cross-site-accountsEvery social account owned by the user across every site

All routes are tenant-scoped via requireAdminAccess.

MCP / programmatic surface

Every write surface for tweets, LinkedIn posts, and Threads posts accepts an optional post_id — create, patch, and campaign. Artifacts created via MCP land in the right dashboard cell immediately, no backfill needed.

Toolpost_id
post_tweetsOptional UUID in body
post_linkedinOptional UUID in body
post_threadsOptional UUID in body
patch_tweets_by_idNullable UUID (null clears the link)
patch_linkedin_by_idNullable UUID
post_tweets_campaignCampaign-level post_id (applied to every item) + per-item post_id (override)
post_linkedin_campaignSame — campaign default + per-item override

Resolution precedence on campaigns: item.post_id ?? campaign.post_id ?? null.

New tool: get_post_distribution(post_id, domain) — returns the full per-platform roll-up for chat workflows that ask "where is this post?". Maps to GET /api/v2/posts/{post_id}/distribution.

"Draft this" → Generate with AI (X)

When the modal's "Generate with AI" radio is selected for X, the flow is:

  1. POST to /api/admin/twitter/generate-thread-async with { postId, tweetType: 'thread', tweetCount: 5 }
  2. Endpoint loads the post (server-validated against the requesting tenant's site), treats post.description + post.content as the human-authored source material, and forces the journal-entry path off
  3. The inserted twitter_threads row gets post_id set so it appears in the right dashboard cell from the moment of creation
  4. Modal redirects to /admin/drafts/{id} where the existing realtime progress UI takes over
  5. Cross-site posted_via_account_id is recorded when the user posts the draft

Other platforms route to a blank draft and the editor handles AI from there — their generation surfaces are per-editor.

Authorship model

Conforms to BlackOps's AI-Refined Content Authorship Model: the post body IS human-authored — the operator wrote it. The generator receives it as additionalContext, which the existing prompt template reshapes into the platform's native format. AI does not invent claims or opinions.

Your daily distribution loop

  1. Publish a post via the normal flow
  2. Visit /admin/posts — the four-dot indicator shows the new post as none across all four platforms
  3. Click any empty dot to drill into the per-post view, or click any "+ Draft" cell on /admin/distribution to draft from there directly
  4. For X, hit "Generate with AI" — comes back as a draft thread; review, edit, publish from your account of choice (including cross-site accounts)
  5. For LinkedIn / Threads / Newsletter, hit "Blank draft" — lands in the platform's editor with post_id already set

Onboarding an existing site with months of un-linked artifacts

  1. From an admin shell: POST /api/admin/distribution/backfill with { "dry_run": true }
  2. Inspect the proposals array — every match has a confidence score and a reasons array explaining the signal
  3. If matches look right, re-POST with { "dry_run": false }
  4. Reload /admin/distribution — dashboard now populated

Out of scope (future work)

  • Generate-from-post for LinkedIn / Threads / Newsletter — those platforms don't have a comparable async-generator endpoint that accepts unstructured source material, so it would need a different abstraction (likely a unified generateContent({ platform, source, ... }) lib).
  • Campaign vs single indicator for X
  • Time-since-publish stale flag
  • Distribution templates per site
  • Cross-site amplification sequencing templates ("main account posts thread, personal quote-tweets 30 min later")
  • GA4 / UTM roll-up per post
  • Morning brief on under-distributed posts
Want this page as machine-readable markdown? GET /docs/features/post-distribution.md