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 linked — POST /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 toposts(id)linkedin_posts.post_id— nullable FKthreads_posts.post_id— nullable FKnewsletters.featured_post_id— nullable FK (the 1:1 case)newsletter_posts— new join table for digest newsletters that feature multiple posts;site_idintegrity 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
| Status | Meaning |
|--------|---------|
| none | No artifact links to this post on this platform |
| draft | At least one artifact exists; none scheduled or published |
| scheduled | At least one artifact has a future scheduled_for |
| published | At 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
| Endpoint | Purpose |
|---|---|
| GET /api/admin/distribution | Index — 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]/distribution | Per-post detail with full artifact metadata |
| POST /api/admin/posts/[id]/distribution/draft | Create a blank draft pre-linked to the post |
| POST /api/admin/distribution/backfill | Heuristic backfill of unlinked artifacts |
| PATCH /api/admin/distribution/link | Manual artifact-to-post link override |
| GET /api/admin/cross-site-accounts | Every 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.
| Tool | post_id |
|---|---|
| post_tweets | Optional UUID in body |
| post_linkedin | Optional UUID in body |
| post_threads | Optional UUID in body |
| patch_tweets_by_id | Nullable UUID (null clears the link) |
| patch_linkedin_by_id | Nullable UUID |
| post_tweets_campaign | Campaign-level post_id (applied to every item) + per-item post_id (override) |
| post_linkedin_campaign | Same — 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:
- POST to
/api/admin/twitter/generate-thread-asyncwith{ postId, tweetType: 'thread', tweetCount: 5 } - Endpoint loads the post (server-validated against the requesting tenant's site), treats
post.description + post.contentas the human-authored source material, and forces the journal-entry path off - The inserted
twitter_threadsrow getspost_idset so it appears in the right dashboard cell from the moment of creation - Modal redirects to
/admin/drafts/{id}where the existing realtime progress UI takes over - Cross-site
posted_via_account_idis 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
- Publish a post via the normal flow
- Visit
/admin/posts— the four-dot indicator shows the new post asnoneacross all four platforms - Click any empty dot to drill into the per-post view, or click any "+ Draft" cell on
/admin/distributionto draft from there directly - For X, hit "Generate with AI" — comes back as a draft thread; review, edit, publish from your account of choice (including cross-site accounts)
- For LinkedIn / Threads / Newsletter, hit "Blank draft" — lands in the platform's editor with
post_idalready set
Onboarding an existing site with months of un-linked artifacts
- From an admin shell:
POST /api/admin/distribution/backfillwith{ "dry_run": true } - Inspect the
proposalsarray — every match has a confidence score and areasonsarray explaining the signal - If matches look right, re-POST with
{ "dry_run": false } - 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