Post Scheduling — Technical Reference
Post scheduling (Fase D.6 / roadmap 4.14) adds a nullable scheduledAt to posts plus a cron worker that releases due posts. Before this, posts only had isPublished: Boolean (default true) and there was no future-publish concept at all.
Where this lives
Backend
apps/backend/database/migrations/20260717090000-add-scheduled-at-to-posts.js— addsscheduled_at(nullable timestamp) + an index to theposttable (idempotent).apps/backend/database/models/Post.js— thescheduledAtattribute.apps/backend/validators/post.validator.js—validateCreateInputacceptsscheduledAt, rejects malformed dates and anything more than ~1 year out.apps/backend/managers/post-managers/post.manager.js—createPost/createStoryhold a future-scheduled post/storyisPublished: false;getScheduledPosts(owner's own),cancelScheduledPost(owner-only, only while still scheduled — deletes the row),updateScheduledPost(owner-only, only while still scheduled — editstext/visibility/scheduledAt), andpublishDueScheduledPosts(the worker's core: flips due posts toisPublished: true, clearsscheduledAt).apps/backend/data-access-services/post/post.access-service.js—getScheduledByUser,getDueScheduled, andgetByUser: non-owner viewers only ever seeisPublished: trueposts, so a scheduled post never leaks into anyone else's view of a profile grid before its time; the owner viewing their own grid additionally sees their own scheduled posts (badged with the release time) so they don't have to open the dedicated scheduled-posts view to see what's queued.apps/backend/services/scheduled-post-publisher.service.js— the cron job (* * * * *, every minute), registered fromindex.jsat startup, mirroring the existingsubscription-reminder.service.jsnode-cron pattern.apps/backend/graphql/types/post.type.js+resolvers/post.resolver.js—scheduledAtonPost,PostCreateInputandStoryCreateInput, themyScheduledPostsquery, and thecancelScheduledPost/updateScheduledPost(ScheduledPostUpdateInput) mutations.
Frontend
apps/frontend-nextjs/src/components/CreatePostModal.tsx— a "Schedule" toggle that opens a calendar-styleDateTimePickerpanel (shared component from@repo/ui, seepackages/ui/src/DateTimePicker/DateTimePicker.tsx); passesscheduledAt(ISO) only when the chosen time is actually in the future, and relabels the compose button to "Schedule".apps/frontend-nextjs/src/components/stories/CreateStoryModal.tsx— sameDateTimePickerpattern for stories; passesscheduledAtoncreateStory.apps/frontend-nextjs/src/page-components/settings/ScheduledPostsPage.tsx(routed at/settings/scheduled-posts, linked from the settings menu) — listsmyScheduledPostswith each post's scheduled time, an edit button (updateScheduledPost, caption + datetime viaDateTimePicker) and a cancel (cancelScheduledPost) button.apps/frontend-nextjs/src/page-components/PublicProfilePage.tsxandapps/frontend-nextjs/src/components/PostModal.tsx— badge the scheduled-release time on a post the owner sees in their own profile grid / post detail modal (!post.isPublished && post.scheduledAt).
Technical implementation checklist
-
scheduledAtonPost/PostCreateInput;createPostholds future posts unpublished -
myScheduledPostsquery —ScheduledPostsPage.tsx -
cancelScheduledPostmutation — cancel button on the same page -
updateScheduledPostmutation (ScheduledPostUpdateInput: text/visibility/scheduledAt) — edit button on the same page -
scheduled-post-publisher.service.jscron worker — publishes due posts every minute -
getByUserhides scheduled posts from non-owner viewers; the owner sees their own scheduled posts on their profile grid, badged with the release time - Scheduling stories —
StoryCreateInput.scheduledAt(CreateStoryModal.tsx); the story's 24hexpiresAtwindow starts when the sweep publishes it, not at creation - Scheduling clips — a clip is just a post whose only media is a video (
type: 'clip', set automatically increatePost), so it's already covered by regular post scheduling; no separate clip-scheduling UI
GraphQL API
# Schedule a post: pass a future ISO timestamp. Omit (or pass a past time)
# to publish immediately.
mutation CreateScheduledPost($input: PostCreateInput!) {
createPost(input: $input) { id scheduledAt isPublished }
}
# input: { text: "...", visibility: public, scheduledAt: "2026-08-01T15:00:00Z" }
# The caller's own scheduled (not-yet-published) posts, soonest first.
query MyScheduledPosts($limit: Int, $offset: Int) {
myScheduledPosts(limit: $limit, offset: $offset) {
id text scheduledAt visibility
media { mediaUrl thumbnailUrl mediaType }
}
}
# Cancel before it publishes (owner only, only while still scheduled).
mutation CancelScheduledPost($postId: ID!) {
cancelScheduledPost(postId: $postId)
}
# Edit a still-scheduled post's caption, visibility and/or time (owner
# only, only while still scheduled; scheduledAt must be in the future).
mutation UpdateScheduledPost($postId: ID!, $input: ScheduledPostUpdateInput!) {
updateScheduledPost(postId: $postId, input: $input) { id text scheduledAt }
}
# Schedule a story the same way; its 24h window starts when it's published.
mutation CreateScheduledStory($input: StoryCreateInput!) {
createStory(input: $input) { id visibility }
}
How releasing works
createPost/createStory store a future-scheduled post (or story) with isPublished: false and the chosen scheduledAt. Feed queries and other users' profile grids all filter isPublished: true, so nobody but the owner sees it before its time. Every minute, scheduled-post-publisher.service.js calls post.manager.js#publishDueScheduledPosts, which finds posts where isPublished = false AND scheduledAt <= now (getDueScheduled, backed by the scheduled_at index) and updates each to isPublished: true, scheduledAt: null — and, for a scheduled story (type === 'story'), also sets a fresh expiresAt 24h out from the actual publish moment, since a scheduled story doesn't start its 24h window until it's actually released. From that point the post/story is indistinguishable from one published normally. Worst-case latency is ~60 seconds past the scheduled minute. Each post is updated independently, so one failure doesn't block the rest of the batch.