Publishing
Publishing puts a post on a social feed. It is a different domain from messaging: no thread, no
contact, no conversation — one piece of content fanned out to several places at once. Scope is
publish, and nothing here touches the inbox.
The interesting part is the fan-out. A single post targeting Instagram Reels, a Facebook feed and a LinkedIn feed is three upstream calls that can each succeed, fail or — worst case — leave you unsure. The model exists to make that survivable.
Two models
model Post {
id String @id @default(cuid())
tenantId String
status String @default("draft")
content String?
targets Json
results Json?
scheduledFor DateTime?
publishedAt DateTime?
errorMessage String?
dispatches PostDispatch[]
}
model PostDispatch {
id String @id @default(cuid())
postId String
groupKey String
integrationKey String
dispatchId String
sendState String @default("pending")
externalId String?
lastError String?
@@unique([postId, groupKey])
}
Post is what you author. PostDispatch is the crash-safe per-surface state machine: one row per
fan-out group, unique on (postId, groupKey), with dispatchId forwarded upstream as the provider's
idempotency key.
That pairing is what makes a retry safe. The unique constraint means a retry finds the existing row
rather than creating a second one, and the stable dispatchId means the upstream recognises the
retried call as the same request instead of publishing twice.
SendState
| State | Meaning |
|---|---|
pending |
The dispatch row exists; nothing has been sent |
sending |
The upstream call is in flight |
sent |
Confirmed, with an externalId |
unknown |
The call threw and we do not know whether it landed |
unknown is the state that earns the whole design. If a request times out, or the process dies
between the upstream call and the response, "did this publish?" has no answer locally. Recording that
as failed would be a lie a retry then acts on — republishing something that is already live. So the
state machine has a fourth value that means exactly "we do not know", and the dispatch stays parked
under its dispatchId so a retry is idempotent upstream rather than blind.
retryPost short-circuits on sendState === 'sent', so a retry only touches dispatches that never
confirmed.
PostStatus
draft, scheduled, processing, published, partial, failed.
The overall status is derived from the per-surface results, not stored independently:
if (anyPending) return 'processing';
if (anyPublished && anyFailed) return 'partial';
if (anyFailed) return 'failed';
return 'published';
partial is the one to handle. It means some surfaces are live and some are not — the Instagram
Reel published, the LinkedIn post did not. There is no honest way to collapse that into published
(you would claim a post is live where it is not) or failed (you would invite a retry that
republishes what already succeeded). A client that treats partial as a synonym for either is the
bug this status exists to prevent. results carries the per-surface breakdown; retry the post and
only the unconfirmed dispatches move.
publishedAt is stamped only when the surface status and the overall status are both published.
A create responds immediately as processing (or scheduled), and completion arrives later via
provider webhooks and GET /v1/posts/live-status.
Surfaces
PublishSurface is feed, reel or story. A target is one platform plus one surface, and the
groupKey of a dispatch is derived from network:surface.
Routes
Seven, all scope publish. Writes require an Idempotency-Key — see
Idempotency.
| Method | Path | Notes |
|---|---|---|
| POST | /v1/posts |
201 |
| GET | /v1/posts |
Cursor-paged |
| GET | /v1/posts/live-status?ids=a,b,c |
Poll several posts' status at once |
| GET | /v1/posts/:id |
|
| PATCH | /v1/posts/:id |
Reschedule or edit; propagates upstream where the provider allows it |
| POST | /v1/posts/:id/retry |
201; only unconfirmed dispatches |
| DELETE | /v1/posts/:id |
204; attempts an upstream delete for published surfaces |
GET /v1/posts/live-status is declared before GET /v1/posts/:id so the static segment is matched
as a route rather than captured as a post id. It takes a comma-separated ids list, which is what a
composer polls after a create instead of fanning out one request per post.
Validating before you get a 422
@~inbox/constraints (0.1.0) is a standalone package with no service dependency, published so a
composer can refuse impossible content in the editor rather than discovering it from the API.
import {
validateContent,
validatePublishTargets,
getSupportedSurfaces,
getMinCharacterLimit,
requiresMedia,
suggestSurfaceRerouteForAttachment,
} from '@~inbox/constraints';
const result = validateContent({ /* text, media, targets */ });
if (!result.valid) {
// render result.errors per platform, before anything is sent
}
PLATFORM_CONSTRAINTS holds per-platform, per-surface rules: character limits, media count ceilings,
byte sizes, accepted formats, aspect ratios, minimum dimensions and video duration bounds. Instagram,
for example, allows 2,200 characters of text, up to 10 images at 30 MB each in jpeg/jpg/png, with an
aspect ratio between 0.8 and 1.91 and a minimum of 320×320.
Two helpers are worth wiring into a composer specifically:
requiresMedia(platforms)/getPlatformsRequiringMedia(platforms)— some surfaces cannot be text-only. An Instagram feed post with no media is rejected with "Instagram feed requires media." Catch that in the editor, not after upload.suggestSurfaceRerouteForAttachment(...)— when an attachment is wrong for the chosen surface but right for another, this proposes the reroute (a video that will not fit a feed post but is a valid reel), so the composer can offer a fix instead of only an error.
getMinCharacterLimit(platforms) gives the tightest limit across a multi-target post, which is the
number a shared character counter should show.
The same rules run server-side as a pre-publish guard, so the package is a convenience for the
client, never the only enforcement. Validation failures at create come back as
validation_failed with a summarised message.