Creating a post
Composer to live link: variants, the approval gate, the publisher's claim, and what each failure looks like.
docs/POST_FLOW.md
From an empty composer to a live post with a link you can click. Every gate below exists to make the third non-negotiable in PROJECT_BRIEF.md structurally true — nothing publishes without a recorded approval — rather than a convention somebody remembers.
One post, many variants
The shape everything else follows: a Post holds the idea, and a PlatformPost holds what each channel will actually receive. They have separate statuses, and that is the point — a post can be live on LinkedIn and still failing on Instagram.
The Post's own status is derived from its variants, never set directly. Once every variant is
published, the post is. That is why a partly published post keeps a schedule form for the
variants that have not gone out.
The whole path
Two things worth reading off that.
An external contributor never chooses. An agency or client author produces a proposal
whatever the project's setting says. The check is not a UI convenience — their post cannot reach
the publisher at all.
Approval is per project, overridable per post. requires_approval on the payload wins; an
absent one falls back to project.requiresApproval.
The status machine
posts/state.ts holds the only legal moves, and refuses an illegal one rather than recording it.
Four edges carry most of the meaning:
publishing → scheduledis a retry, not a rollback. It is how a stuck claim is released.on_holdcannot go straight toscheduled. A client hold parks the whole post out of the publish path; you un-hold toapprovedfirst, so nothing escapes a hold by a side door.failed → publishinglets a failed attempt be retried directly, without redoing the work.publishedhas no way out. It is terminal because the post exists in the world now.
publishing and published are also protected: an accidental path — deselecting an account
in the composer, an autosave sync — may never delete them, because that would cascade away the
analytics and audit rows. Explicit deletion still works; that is the user's call.
Publishing
The claim is the concurrency control. Two worker replicas both see the same due row; only one
gets a non-zero update from scheduled → publishing, and the other moves on. No separate lock
table, and no window where a row is claimed but unmarked.
A crashed worker leaves rows stuck in publishing. reapStalePublishing finds them on the next
tick and returns them to scheduled, which is what that retry edge is for.
What a reader can check afterwards
Every published row carries the permalink the platform returned, so "published" is something
you can click rather than something we assert. Where a platform gives no derivable URL,
derivePermalink returns null rather than guessing — a link that 404s is worse than no link.
The Publish log (/projects/:id/publish-log) answers the morning question — did everything
go out — ordered by what needs a person: overdue first, grouped by cause, settled below.
The endpoints
| Step | Call |
|---|---|
| Upload media | POST /api/v1/uploads |
| Create the post + variants | POST /api/v1/posts |
| Edit it | PATCH /api/v1/posts/:id |
| Add a channel later | POST /api/v1/posts/:postId/platform-posts |
| Schedule or re-time a variant | PATCH /api/v1/posts/:postId/platform-posts/:id |
| Publish one now | POST /api/v1/posts/:postId/platform-posts/:id/publish |
| Send for review | POST /api/v1/posts/:postId/approvals |
| Decide | POST /api/v1/approvals/:id/decisions |
| See what went out | GET /api/v1/posts · the Publish log |
Where it goes wrong, and what each looks like
| Symptom | What it means |
|---|---|
| Says Scheduled but nothing happened | the worker is not running — GET /api/v1/health must show worker: up |
| One channel failed, others live | normal: variants have their own status. The row carries publish_error verbatim |
Stuck in publishing | a worker died mid-publish. reapStalePublishing returns it to scheduled |
| Approved, but the post has no publish date | the approval landed with no scheduled_at on the post — approval and scheduling are separate decisions |
| Published with no link | that platform exposes no derivable permalink. Deliberately null rather than a guess |