Add POST /api/posts (publish now), GET /api/posts and GET /api/posts/{id}

Merged
SW
7d ago
+2257-0

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

MethodPathBehavior
POST/api/postsValidates, checks the platform is connected for this account (409 if not), inserts a publishing row, starts publishPostWorkflow, returns 202 { id, status } + Location
GET/api/postsAccount'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 + executePostAction pin the Composio connectedAccountId. 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 posted sits 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 with media_media_ids → text read-back (t.co suffix and HTML entities normalized).
    • YouTube: upload unlisted → YOUTUBE_UPDATE_VIDEO with final metadata + privacy (default public) → snippet read-back. No thumbnail call (owner rule 2026-10-13). A quota 429 is recorded as failed with 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 with text_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.