Add POST /api/posts (publish now), GET /api/posts and GET /api/posts/{id}
Publish-now for the posts API (step 3 of recoupable/app#2241: docs ✅ → database ✅ → api publish-now → api scheduling + cancel). Implements the published contract (docs: Create Post) on the scheduled_posts table from database#116.
Endpoints
| Method | Path | Behavior |
|---|---|---|
POST | /api/posts | Validates, checks the platform is connected for this account (409 if not), inserts a publishing row, starts publishPostWorkflow, returns 202 { id, status } + Location |
GET | /api/posts | Account's posts, newest first; account_id override, status, platform, limit, offset |
GET | /api/posts/{id} | One post (polling target); 403 for another account's post, 404 when missing |
publish_at is rejected with a 400 in this PR ("Scheduling is not available yet"). Scheduling (sleep(publish_at)) and DELETE /api/posts/{id} are the next PR.
Validation (matches the docs table)
Strict body (unknown keys → 400). YouTube needs title (≤100), description ≤5,000; Instagram caption ≤2,200; LinkedIn commentary ≤3,000; X ≤280 weighted (countXWeightedLength: URLs 23 with or without scheme, CJK/emoji 2). Fields a platform does not use (privacy/tags off YouTube, cover_url on YouTube, sub_account_id off LinkedIn, title on IG/X) are rejected, not silently dropped.
Publishing (app/workflows/posts/)
- Runs exactly once.
publishPostStep.maxRetries = 0: a retry after a partial success (post created, read-back failed) would publish twice. - Only the account's own connection.
findConnectedAccountId+executePostActionpin the ComposioconnectedAccountId. The chat tool router falls back to Recoup's shared connections, which for posting would publish to the wrong account. - Live post is never marked failed. Recording
postedsits outside the try, with normal retries. - Per platform (ported from our proven posting scripts):
- Instagram: Reel container (cover if given) → publish (waits for processing) → permalink + caption read-back.
- X: stage video →
TWITTER_UPLOAD_LARGE_MEDIA(retried while X processes) → post withmedia_media_ids→ text read-back (t.co suffix and HTML entities normalized). - YouTube: upload unlisted →
YOUTUBE_UPDATE_VIDEOwith final metadata + privacy (default public) → snippet read-back. No thumbnail call (owner rule 2026-10-13). A quota 429 is recorded asfailedwith the platform message. - LinkedIn: native video via Composio proxy (initialize → PUT parts/thumbnail to pre-signed URLs → finalize → wait AVAILABLE → post as member, or as the Page in
sub_account_id) → commentary read-back. Text is escaped to LinkedIn "little text" (hashtag templates).
text_verified= live text strictly equals the submitted copy.
New code is in lib/publishing/ (lib/posts/ is the existing scraped-artist-posts read). types/database.types.ts gains scheduled_posts.
Tests
44 new unit tests (validation incl. limits/unused fields/publish_at, X weighting, LinkedIn escaping, handlers 202/409/400/403/404/500, workflow posted/failed/never-fail-a-live-post, step maxRetries = 0 + connection gone, each publisher's call sequence incl. no YouTube thumbnail and Page author). Full suite 6,475 pass; lint, format, tsc clean.
Preview testing (pending owner go-ahead)
Preview deployments share the production database and connectors, so a preview POST really publishes. Plan: read-only checks first (validation 400s, 409 on an unconnected platform, GET /api/posts), then one real post per platform on the owner-chosen account, verifying posted, a working post_url and text_verified: true (YouTube may end failed on the shared quota, as expected). Results + screenshots go in a comment here.
After merge
Drop the x-recoup-availability: pending flags for the three shipped operations in the docs (marketing → docs sync).
🤖 Generated with Claude Code
Summary by cubic
Adds the publish-now step of the posts API (recoupable/app#2241) plus the read endpoints. POST /api/posts validates a post against the published contract, starts a durable workflow that publishes the video and copy to the account's connected Instagram, X, LinkedIn (member or Page), or YouTube account, and returns 202 { id, status } with a Location header for polling. GET /api/posts lists an account's posts (status, platform, account_id, limit, offset filters) and GET /api/posts/{id} returns one; a 403 guards other accounts' posts and a missing one is 404. publish_at is rejected with "Scheduling is not available yet"; scheduling and DELETE /api/posts/{id} come in a follow-up.
Safety notes
- The publish step never retries (
maxRetries = 0), so a partial success cannot publish twice. - Publishing always uses the account's own connection; Recoup's shared fallback would post to the wrong account.
- A live post is never marked
failed; a failed read-back records the post as live withtext_verified: false(Instagram falls back to the profile URL). - Validation is strict: per-platform required copy and limits, X weighted 280-character counting (URLs 23, trailing punctuation excluded), and fields a platform does not use are rejected with a 400.
- X text is read back via the single-post lookup, which works on our connections; the plural lookup returns 401 and made every X post
text_verified: false.
Summary by CodeRabbit
- New Features
- Added post publishing to Instagram, X, LinkedIn, and YouTube for connected accounts.
- Added endpoints to submit posts, retrieve a post by ID, and list posts with platform, status, and pagination filters.
- Added platform-specific content validation, including required fields and text-length limits. Requests with a publish time are rejected; scheduled publishing is unavailable.
- Post records include publishing status, a link to the published post, and an indication of whether the published text matches the submitted copy.