Add the repurpose module - #335
Open
paulocastellano wants to merge 87 commits into
Open
Conversation
Two tables: repurposes (source account, destinations, status, poll watermark) and repurpose_items (one row per source video seen, with the skip or failure reason). Posts gain a nullable repurpose_item_id so every generated post traces back to the video it came from. A source account maps to exactly one repurpose (unique per workspace and account, never per network), so each account is polled once per cycle. InstagramSourceFetcher and FacebookSourceFetcher list recent media for a connected account; classification of what to skip belongs to the caller.
PollRepurposes runs every five minutes and dispatches only repurposes that are due; the real cadence is REPURPOSE_POLL_INTERVAL_MINUTES (default 15), so it can be tuned without a deploy. Meta's Instagram quota is an app-wide pool, and this feature's user configures it once and stops opening the app, so a throttled source backs off for REPURPOSE_BACKOFF_MINUTES instead of spending the pool every tick. Polling logs every media id it sees, with the reason it was skipped: not a video, already published through TryPost, or no downloadable URL (Meta omits it for copyrighted audio). Only genuinely new videos reach ProcessRepurposeItem. That job creates one post per destination rather than one post with many platforms, because a post carries a single caption every publisher reads. A Reel keeps its 2,200 characters even when a YouTube Short in the same repurpose is capped at 100. The video is downloaded once and shared. CaptionAdapter only spends AI on a real overflow; without AI access it cuts on a word boundary and the post still publishes.
Creation follows the changelog pattern: a dialog that only asks for the source account, then a redirect to the full edit page where destinations, status and activity live. Only Instagram and Facebook accounts are offered as a source, since they are the only networks that let us download the video. The destination picker lists accounts rather than networks, so a workspace with two Instagram accounts can send to both. Translations for all 16 locales, plus the sidebar entry.
Network logos now use the same tile as the accounts grid: the network's colour, the slight tilt that straightens on hover, and a hard border. Source and destination pickers are tiles rather than a select, so the network is visible at a glance, and every screen leads with the flow from source to destinations. The empty state no longer duplicates the templates below it, the create dialog links to the accounts page when nothing can be a source, and the save button uses a real translation key instead of the missing common.save.
…ublishes Modelled on repurpose.io: a repurpose watches exactly one source format (Reels, videos or Stories) and each destination chooses the format it publishes as, so a Story from Instagram can land as a Reel on Facebook. Because one repurpose watches one format, a creator replicating both their Reels and their Stories needs two on the same account. The unique index on (workspace_id, source_social_account_id) is therefore dropped and polling groups by source account instead: two repurposes on one Instagram share a single round of calls, which is what the Meta quota actually cares about. Instagram distinguishes a Reel from a feed video only by media_product_type and excludes Stories from /media, so Stories come from their own edge and only when a repurpose watches them. A Facebook Page splits the same three across /video_reels, /videos and /stories; Reels are removed from the plain video list so a Page watched for both never replicates one twice, and a story's downloadable file is resolved from the media id it returns. Destinations only offer formats that accept video, validated server-side, and open on the closest match to what the source watches. A plain sentence at the top of the page says what the configuration will actually do.
Eleven MCP tools and eleven API endpoints, both driven by the same actions and the same RepurposeRules, so a destination's per-platform meta cannot be accepted on one surface and silently dropped on another. Destinations are laid out three to a row and the danger zone moves to its own tab, leaving configuration to the source and its destinations.
MySQL refuses to drop the only index backing a foreign key (SQLSTATE 1553), so the replacement index on (workspace_id, source_social_account_id) is created in its own statement before the unique comes out, and down() puts the unique back before removing that index. Verified by running the full suite against MySQL 9.4 as well as PostgreSQL. Adds the browser test and the README row the plan called for.
The destination picker was a bespoke list that only ever stored an empty meta, so a repurpose to TikTok, Pinterest or Discord activated cleanly and then turned every replicated video into a failed post: each of those needs a privacy level, a board or a channel before anything can be published. It is replaced by ChannelConfigurator, the same component the post editor uses, fed with the same platform configs, Pinterest boards and TikTok creator info. Every network's settings therefore come from the component that already knows how to ask for them, instead of being rebuilt here. ActivateRepurpose now refuses a destination missing its required meta, asking PostPlatformMetaRules rather than repeating the list, so a misconfigured repurpose cannot go active in the first place.
The tab was one long stack, and every network added to it: tiles, a settings panel, then another. On a wide screen the source and its status now sit in a narrower column that stays put while the destinations scroll beside them, so adding a fifth network no longer pushes everything else off the screen. The save button trails the destinations and sticks to the bottom of that column. Below the large breakpoint the two columns stack.
The page opened with a strip of network logos and repeated itself: the title was the source account, a green box restated the configuration, and the destinations section said the same thing a third time. Now the plain sentence is the subtitle, directly under a title that names the module and a badge that says whether it is running. It names the source account, so it also tells one repurpose from another, and it updates as the destinations change. The logo strip and the green box are gone, the destinations card explains its own control instead of the concept, and the save button appears only once something changed, without a rule above it. The sidebar entry carries the beta badge the automations entry used to.
The detail column stretched to whatever the longest reason happened to be, pushing the destinations column out of view behind a horizontal scroll. It now absorbs the spare width, wraps to three lines and keeps the raw error one hover away, while the short columns stay on one line. The destinations column listed identical "open post" links, which said nothing when a video went to three networks. Each link now carries the network it published to, which needed the web page to serve items through the same resource the API already used. Also swaps the browser's native colour input in the Discord embed settings for the app's own hex picker, normalising shorthand and alpha values back to the six digits Discord accepts.
Disabling a repurpose was a ghost button, which reads as the least important thing on the card rather than the one that stops it running. It now uses the destructive variant, the same red as disconnecting an account. The delete control in the list matches the one the webhooks table uses.
…g resumable Account ids were validated as UUIDs and nothing more, so a member of one workspace could name another workspace's account as a source or a destination: polling would then read someone else's Instagram with their token, and processing would publish to their accounts. Every surface now uses the same workspace-scoped exists rule the post editor uses, and a source must additionally be a network we can download from. Processing was not resumable. An attempt that died after creating some posts left them as drafts, and the retry saw those posts and returned early, so they were never published and the item stayed processing forever. A retry now clears what the previous attempt left behind and starts over. A repurpose whose creator has since left the workspace falls back to the workspace owner instead of failing on a null user, and the download-failure cleanup no longer relies on an array union that would have dropped the post it meant to delete. Both jobs drop themselves when their model is gone rather than failing noisily after a repurpose is deleted.
Security. A social account's meta carries the plaintext Meta user token and was reaching the page: both repurpose screens shipped raw Eloquent models, and meta was not hidden. It is hidden now, at the model, and the screens go through resources. The update requests re-declared source_social_account_id after spreading the shared rules, so the literal key won and silently threw away the workspace scope added for the store path; a repurpose could be re-pointed at another workspace's account and polled with their token. Correctness. A retry hard-deleted posts that were already publishing and republished them; it now only clears drafts a dead attempt left behind. Resume flipped any status to active, so resuming a disabled repurpose ran with no watermark and replayed the account's whole recent history; it is restricted to paused. A destination switched off still produced a post with no enabled platform, which RecoverStuckPosts then marked published without anything being sent. Captions were stored raw into a column that holds editor HTML, so the sanitizer's strip_tags cut every caption at its first "<". The rate-limit backoff asked GraphError with a synthetic body that can never match a code, leaving an English substring as the only trigger; the fetchers now raise an exception carrying the response so the real classifier runs. The activity log reported an arbitrary synced platform rather than the enabled destination. LinkedIn Pages were offered personal content types because platform matching collapsed the two, and destinations were never cross-checked against their account's network. Invariants. Activation's gate ran once, so an active repurpose could be edited into a state it could never have been activated in; it now re-runs on update and also verifies each destination account still exists, is active and belongs to the workspace. A unique index on source and format replaces a check that two concurrent creates could both pass. Both jobs are unique per account and per item, and a disconnected source advances its clock instead of being redispatched forever. Feedback. Saving destinations and every status action were silent on failure; they report through the toast the rest of the app uses.
Collapse the repeated work in the show page: connected accounts were queried three times per request, and the Pinterest and TikTok lookups they feed re-fired on every infinite-scroll page. They are closure props now, so a scroll page carries only its items. Both Meta source fetchers duplicated the same request-and-throw. A shared base owns it, along with a timeout the raw calls never had. Rate limits come back as HTTP 400 from Meta and the job already backs off on those, so there is no in-request retry. The lookup for media TryPost itself published ran once per repurpose over an unindexed column. It runs once per poll now, against a new index. Also removed: the unused source-format helper, the never-assigned not_video reason and its 16 translations, the template key the frontend sent and the backend dropped, and Templates::find(). Destination meta errors now carry the same friendly names posts already had, the read-only repurpose tools are annotated, and the items tool takes its page size from config rather than a hardcoded 15.
paulocastellano
force-pushed
the
repurpose-module
branch
from
September 5, 2026 22:34
0e6d37e to
e5e23be
Compare
The lifecycle actions trusted the UI. RepurposeStatusCard only offers pause on an active repurpose, but the API and MCP called straight into the action, so pausing a draft and resuming it produced an active repurpose with no watermark — and a poll with no watermark replays the whole recent feed and auto-publishes it. Activate, pause and turn-off now refuse the transitions they were never meant to accept, and resume stamps a watermark if one is somehow missing. An update that failed the activation rules had already been written by the time the error was raised, leaving an active repurpose pointed at a destination it cannot publish to. The write and the check share a transaction now. A failed download was turned into a terminal state, so the retries and backoff the job declares never ran for the one failure that is usually transient. It throws instead, keeping the reason for when the tries run out. Also: the index on post_platforms is built concurrently on PostgreSQL rather than holding a SHARE lock over a table publishing writes to constantly; the destination lookup is scoped to the workspace; poll errors go through the TokenRedactor before they are stored and shown; and the always-null postId argument is gone from the caption adapter.
The job set each post to scheduled and then dispatched PublishPost itself, which is the one combination that collides with posts:process-scheduled: the command claims exactly those posts every minute, so both paths dispatched the same post. PublishToSocialPlatform's uniqueness kept that from double posting, but it was duplicated work either way, and a loop interrupted halfway left the remaining posts scheduled with nothing to pick them up. They are all marked due in one write now and the existing command claims them atomically, which is the flow the module was meant to feed in the first place. An interrupted attempt heals itself within the minute. The download failure reason is also only stored once the tries are actually exhausted, so an item being retried no longer reads as already failed, and a later failure of a different kind no longer inherits the download reason.
The truncation limit was derived from the raw caption's length, which only holds while sanitizing leaves the length alone. It does not: stripping HTML shrinks the text and X's link defusing grows it, rewriting every dot of a host. With defusing on, an 800-character caption came out as a single character, because the overflow measured on the rewritten text was subtracted from the raw length and went negative. Truncation now rescales the cut by how far the sanitized form overshoots and repeats until it fits, so the same caption keeps its full allowance. The AI is told the platform's real limit rather than that same derived number. A destination whose account was switched off also left the page unusable: the picker offered nothing, the stale destination stayed in the form, and saving failed on a message that spelled out destinations.0.social_account_id. The form drops destinations it cannot show, and the account rules carry readable names and messages.
Meta documents media_product_type as available to the Facebook-login API only, and a standalone Instagram account talks to graph.instagram.com. The fetcher classified every row by that field alone and dropped anything it could not place, so on those accounts the whole source could go quiet without a single error to show for it. The surface now comes from the edge that returned the row wherever that is unambiguous — everything off /stories is a story — and a video from /media with no product type is read as a reel, which is what Instagram serves new feed video as.
The repurpose screens build translation keys out of enum values, so adding a case without the matching string shows the raw key to the user and nothing breaks until someone sees it. Every value the interface interpolates is checked against all sixteen locales.
Marking the posts due in a single query skipped the model events, so the draft-to-scheduled transition never reached PostStatusChanged and anything watching the posts list stayed on the old status. The loop is back, wrapped in a transaction so an interrupted attempt still leaves nothing half-done. Facebook resolves the file behind each story with its own request, so a page of stories costs one call per item; at the previous timeout that worst case outlived the queue's own, and a poll killed that way is redispatched every tick without ever recording a result.
The helper always throws, so its call site reads as if execution continues.
The index loaded every repurpose in one response while the rest of the app scrolls. It pages on the config size and scrolls now, the public API keeps its own documented size of 15 like the items endpoint beside it, and the MCP tool takes a page so a client can reach past the first one. The resource carries the source account the flow diagram draws. Meta's reference marks media_product_type and caption as readable by the Facebook-login API only, and a standalone Instagram account talks to graph.instagram.com; the Video node's documented fields do not include permalink_url either. Asking for a field the token cannot have fails the whole read rather than dropping that one value, which would have taken the source down instead of costing it a caption. A read rejected that way is retried once with the fields every token can read; any other error still propagates untouched.
The status guards read the caller's copy of the repurpose, which was loaded before the request, and wrote the new status back without holding anything in between. Two callers arriving together each passed the check the other was about to invalidate, and a copy that went stale mid-request was trusted over the database. Each transition now locks the row, re-reads the status inside, and writes under the same lock, which is what AttachExistingAsset already does for the post status it checks. Updating destinations takes the same lock, since it reads the status to decide whether the activation rules apply.
paulocastellano
force-pushed
the
repurpose-module
branch
from
September 6, 2026 13:57
7735927 to
de485ff
Compare
Switching a social account off means "don't post here", which
ProcessRepurposeItem already honours by skipping that destination. Two separate
places disagreed with that and between them made a deactivated account block
far more than posting to it:
- assertPublishable() demanded every destination resolve to an active account,
and UpdateRepurpose runs that gate whenever the repurpose is Active, so one
switched-off account blocked editing and resuming the whole repurpose.
- The destination rule in three FormRequests carried ->where('is_active', true),
rejecting the payload before any Action ran. The editor round-trips the full
destination list, so the user could not save any edit at all.
The gate is now split into assertSourceUsable() and
assertDestinationsPublishable(); the source stays all-or-nothing, destinations
need one usable entry. UpdateRepurpose runs the destinations half only, so an
unrelated edit no longer fails because of the source account.
Behaviour change: a switched-off account is accepted as a destination. Two
tests that encoded the old rule were inverted rather than removed.
Resume now reads paused_reason to decide the watermark. A pause the user chose keeps its place, so resuming catches up on what was missed. A pause the system imposed moves the watermark to now: replaying a two-day outage would flood the destinations with a backlog nobody asked for. Resume also runs the same health gates as activation, so it can no longer resume into a state that cannot publish. Two existing watermark tests were given a usable source and destination to reach the assertion they care about.
Only a SourceUnavailable pause can auto-resume: SourceRemoved and NoDestinations describe state no account event restores, so they wait for the user. Eligibility is checked before calling Resume rather than discovered from its exception, which would fill the log with expected failures on every verification sweep. isUsable() reads the account from the database instead of the instance. The observer receives whatever model the caller was holding, and is_active is not in SocialAccountFactory, so a freshly created account carries no such attribute in memory. Strict mode exempts recently-created models from the missing attribute exception, so it read back as null and turned a healthy account into a false negative — auto-resume never ran.
An item whose every destination resolved to a removed or switched-off account was marked PostCreationFailed, which says a creation was attempted and failed. Nothing was attempted. NoUsableDestinations says what happened. The poll's skip guard no longer clears last_error or pushes next_poll_at on its way past: the observer is pausing that repurpose, and overwriting the recorded error left the card reading "active, checked just now" while nothing ran. PollRepurposes filters null source ids defensively — orphaned repurposes are paused so they never reach it, but a null must never reach whereKey().
The item status says the job did its work, not that the posts went out. When every post later failed, the activity row still read "Replicated". Rather than rolling the item status up from its posts — which would rewrite history whenever someone edits or deletes a replicated post — each post now carries its own state into the activity list. The truth lives on the post and stays current there.
A repurpose whose source account was deleted now survives, so the pages have to handle a source that is not there: the summary rendered "Replicate Reels from to Facebook" with a hole in it, and the source card offered an empty format list. The banner derives its message from current account health rather than from the stored paused_reason. The reason records why the system stopped, which decides the watermark on resume; it is not a description of the situation the user is looking at, which may already be fixed. So the banner can say "ready to resume" without any new field. The lifecycle toast also reads source_social_account_id, the key the new source gate fails on. Without it, activating with a disconnected source showed the generic "Something went wrong" instead of saying which account to reconnect.
Deleting or switching off a social account is the one path where an automation stops and nothing tells the user in any channel. The account emails do not fire — they did this deliberately — and the repurpose banner lives on a page they may not open for weeks, while the action happened on the accounts page. The flash these two actions already set now says how many automations paused. Nothing is prevented; the disconnect goes through exactly as before and the sentence only reports what else changed. The count is read after the observer has run rather than predicted, and the affected ids are captured before the delete because the source FK is nullOnDelete.
Three fixes, all from reviewing the implemented code rather than the plan: The accounts flash no longer decides direction by reading $account->is_active off the instance. That is the same pattern that silently broke isUsable(): a column the model never loaded reads back as null instead of throwing. It now captures each repurpose's status before the change and compares afterwards, which also lets it report what auto-resumed — previously a switched-off account announced the pause but switching it back on said nothing. The poll's skip guard moves the schedule again. Returning early kept last_error intact, which was the point, but also left next_poll_at in the past, so a repurpose still Active while its account was already unusable — a race with the observer — was re-dispatched on every scheduler tick. reschedule() moves the schedule without clearing the error; markPolled() keeps doing both, which is only right after a poll that actually succeeded. NoUsableDestinations also covers an item whose repurpose has no destinations at all, so its label no longer claims every destination was removed or switched off.
The accounts flash only looked at repurposes the account was the source of, so deleting the last destination account stopped an automation and said nothing — the exact case the flash exists for, since no email covers a deliberate delete. It now matches destinations too, filtered in PHP because they live in a JSON array of objects and partial-object containment needs a different candidate shape on each engine. Also covers three paths the review found untested: pruning a destination from a draft repurpose must not pause it, a content type still valid after a platform change must not be rewritten to the default, and an item whose destination account was deleted outright records NoUsableDestinations.
The Facebook /videos edge lists reels alongside feed videos and carries nothing to tell them apart, so reels are subtracted using the /video_reels edge. That subtraction only ran when the repurpose watched both formats — a page watched for feed videos alone read every reel as a feed video and replicated it. The reels edge is now read whenever videos are wanted, and its rows are dropped from the result unless reels were actually asked for. One extra call per poll, and only for a page watched for feed videos. Also covers the field-fallback path, which had no test at all. It pins what the degraded read actually costs: the public field set carries neither media_product_type nor caption, so every video reads as a reel and the caption arrives empty — the price of not going dark, now written down.
Both share RepurposeResource and the same actions, so paused_reason and the health gate reached them for free — but nothing asserted it, which is exactly how a surface starts drifting. Four tests: the API and the MCP get tool both report why a repurpose stopped, and resuming or activating through either is refused while the source is unusable. The API also accepts a switched-off account as a destination, the behaviour change this branch makes.
The activity query was copied into the API controller and the MCP tool as well
as living in ListRepurposeItems. When the eager load gained the status column —
so each replicated post could report its own state — only the action was
updated, and the resource then read a column the other two never selected.
That is a 500 on GET /api/repurposes/{id}/items and on the MCP items tool
wherever strict mode is on, and a silently null status in production. Both now
call the action.
The action takes the page and the page size instead, because the two contracts
genuinely differ: the app paginates at config('app.pagination.default') and the
public API at a documented 15, which is now a named constant rather than a
literal repeated twice.
The API index reimplemented ListRepurposes exactly — same relation, same count, same ordering — differing only in page size. That is the arrangement that broke the activity list: an eager load added for a new resource field reaches the action and leaves the copy behind. Same shape as the fix there: the action takes an optional page size, and the API passes its documented 15.
An attempt that dies after creating some of its posts leaves them as drafts. Every retry clears them on the way in, but the last attempt has no successor, so they stayed in the calendar with nothing explaining where they came from and no way to tell them from a draft the user wrote. failed() now clears them. Except in draft mode, where the draft is the deliverable rather than a leftover: the user can already see and publish it, so a late failure keeps the work and the item reports that it drafted them.
The poll redacts tokens before storing a failure; item processing stored the raw exception message. Those two paths disagreed, and the item error is the more exposed of the two — it is rendered in the app and served through the public API and MCP. A CDN download URL is a credential: Meta signs it with expiring oh/oe parameters, and an HTTP client puts the whole URL into its message, so a timeout during download published a working signed URL through three surfaces. The job knows exactly which string that is, so it is replaced rather than pattern-matched, and TokenRedactor still covers the OAuth shapes it knows.
getPlatformLogo falls back to the LinkedIn mark for an unknown platform, so a repurpose whose source account was deleted rendered the LinkedIn logo next to an empty name — on the index and on the show page both. It read as a working LinkedIn source rather than a missing one. The flow now has its own state for that, and the tooltip says so instead of labelling it with an empty string. Pinned in the browser test alongside the banner and the summary, which had the same hole and were already fixed.
The show page was given active accounts only, and the form drops any stored destination whose account is not in that list. So switching an account off removed it from the form, and the next save — changing the publish mode, adding another network, anything — wrote the destination away for good. That undoes the rule the rest of the branch establishes: a switched-off destination stays and is skipped at publish time, because switching an account back on should bring it back with it. Destinations now include switched-off accounts, and the card names the ones being skipped. Sources still come from active accounts only: a source genuinely has to work.
Reconnecting a deleted account creates a new row, so the user re-points the orphaned repurpose at it by hand. Nothing covered that end to end, and it is the main way out of the state this branch introduces. The assertion that matters is the watermark: the replacement account carries a back catalogue this repurpose never watched, and both the update and the resume have to agree on starting from now rather than replicating thirty days of it.
Dropping the is_active clause from the destination rule reached three FormRequests and stopped there. The MCP has its own request classes — separate on purpose — and they still rejected a switched-off destination, so an agent round-tripping the destination list it had just been given could not update a repurpose the editor and the API had no trouble with. Source rules keep their is_active clause on every surface: a source has to work.
Removing the try/catch around RepurposeAccountSync broke no test, which is the worst kind of gap: it is protective code, exercised only when something else goes wrong. The delete hook runs inside $account->delete(), so an exception escaping it turns disconnecting an account into a 500 caused by a side module, and a reconnect wraps its update in a transaction that would roll back. Malformed destinations make the typed closure throw, which is a real enough way in. The disconnect still succeeds and the failure is logged.
Removing the terminal check broke no test. The later "already has posts" guards hide that for published items, but a skipped one carries no posts at all — so a video deliberately skipped, because it was already published through TryPost or had no downloadable file, would be replicated on the next delivery of the job. Queues redeliver: a worker restart or a visibility timeout is enough.
The source returns the same page every interval, so only the first sighting is work. Two things enforce that — the wasRecentlyCreated check in logMedia and ProcessRepurposeItem being ShouldBeUnique on the item id — and the second masks the first for an hour. The test pins the outcome rather than either mechanism, and says so, because removing the check alone does not fail it.
Removing the paused_reason reset from Disable or Activate broke no test. It has a visible consequence: the index badges a non-null reason as "stopped on its own", so a repurpose the user turned off themselves would claim the system did it, and an activated one would carry a reason describing a state it is no longer in. Resume already had coverage because the reason decides its watermark. These two only tidy up, which is exactly why nothing noticed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Repurpose replicates short videos a workspace publishes outside TryPost, in the Instagram or Facebook app, to its other networks. It exists for the creator who does not schedule through TryPost and still wants every video on every network.
The module never publishes anything itself. It discovers new source media by polling, downloads the video with the existing
MediaAttacher, creates ordinary posts withcreated_via = repurpose, and hands them toPublishPost. Retries, the calendar, analytics andpost.publishedwebhooks all come for free.Content published through TryPost is never replicated: the source media id is matched against
post_platforms.platform_post_idin the workspace.How it is configured
Modelled on repurpose.io. A repurpose watches one source account for one video format, and each destination chooses the format it publishes as, so a Story from Instagram can land as a Reel on Facebook.
Because one repurpose watches one format, replicating both Reels and Stories takes two of them on the same account. Polling therefore groups by source account: two repurposes on one Instagram share a single round of calls.
Meta quota
The Instagram Platform Rate Limit is an app-wide pool of
200 x daily active usersper hour, and this feature's user configures it once and stops opening TryPost, spending quota without feeding the denominator. Three things keep that in check:next_poll_atdecides what is due, so the real cadence isREPURPOSE_POLL_INTERVAL_MINUTES(default 15) and can be dialled without a deploy.REPURPOSE_BACKOFF_MINUTES(default 60) instead of retrying next tick.Facebook Pages and Instagram-via-Facebook use Business Use Case limits, which scale with the creator's own audience, so they are not a concern.
Platform behaviour worth knowing
VIDEOfor both a Reel and a feed video;media_product_typeis the only thing telling them apart./media; they live on their own edge and last 24 hours./video_reels,/videosand/stories./videosalso lists Reels, so Reels are removed from the plain video list and a Page watched for both never replicates one twice.media_urlfor copyrighted audio; those are logged as skipped with the reason, not as failures.One post per destination
A post carries a single
contentthat every publisher reads, and there is no per-platform caption column. So the job creates one post per destination, each with its own adapted caption. That matters: a single post with all four networks would have to fit YouTube's 100-character title, destroying an 800-character Instagram caption. The accepted trade-off is that a video replicated to three networks is three calendar entries.Captions are only touched when they overflow the destination's limit, measured with the same
contentOverflow()callContentFitsPlatformLimitsmakes. AI shortens them when available and metered; without AI access the caption is cut on a word boundary and the post still publishes.Surfaces
Web, REST API and MCP, all driven by the same actions and the same
RepurposeRules, so a destination's per-platform meta cannot be accepted on one surface and silently dropped on another.GET /repurpose-templates; items paginated at the documented 15.The activity tab is the support surface: every source video the module saw, with why it was skipped when it was.
When the accounts break
A repurpose depends on accounts it does not own the lifecycle of, and every way one can fail is now handled. Source and destination are treated asymmetrically on purpose: a dead source stops the automation, while a dead destination keeps flowing to the publisher, which fails the post visibly and lets the user retry it after reconnecting. Skipping a destination at job time would be permanent for that item, since items are never retried.
nullOnDeleteinstead ofcascadeOnDelete, which used to destroy the repurpose and every activity row silently. Pauses, and the page asks for a new source.repurposes.paused_reasonrecords whether the system stopped it or the user did. That single fact decides two things: whether Resume replays the backlog, and whether the system may resume on its own. It is never UI copy — banners read current account health instead, so they can say "ready to resume" the moment the cause is fixed.RepurposeAccountSyncruns fromSocialAccountObserverand can never throw: the delete hook runs inside$account->delete(), and a reconnect wraps its update in a transaction, so an exception there would 500 a disconnect or roll back a reconnect.No new email.
markAsTokenExpired()andVerifyWorkspaceConnectionsalready mail about the account, and reconnecting is exactly what resumes the repurpose. Deleting or switching an account off is something the user just did on the accounts page, so the flash there reports how many automations it paused — or resumed — instead.Two fixes that came out of this and stand on their own:
assertPublishable()demanded that every destination be active, andUpdateRepurposeruns that gate whenever the repurpose is Active — so deactivating one account blocked resuming and editing any repurpose that listed it. The destination rule in three FormRequests carried the sameis_activeclause, rejecting the payload before any action ran, and the editor round-trips the whole list. Both are gone: one usable destination is enough. Behaviour change: a switched-off account is now accepted as a destination, and skipped at publish time like any other.RefreshSocialTokenonly stampedlast_verified_at, so a connection fixed by the hourly refresh stayedTokenExpireduntil the daily sweep — keeping everything that depends on it stopped for up to a day longer than necessary.The activity list also stops overstating itself: an item marked "Replicated" meant the job created the posts, not that they went out. Each post now carries its own status into the list. No roll-up onto the item, which would rewrite history whenever someone edits or deletes a replicated post.
Schema
Two new tables,
repurposesandrepurpose_items, plus a nullablerepurpose_item_idonpostsso every generated post traces back to the video it came from.CreatedViagains arepurposecase.Test plan
php artisan test --compact --parallel: 4353 passed, 1 skippedtests/Feature/Repurposeon MySQL: 175 passed, plusmigrate:rollbackandmigrateboth clean — the drop-change-recreate ordering the new migration is written forLocalizationParityTestgreen; all 16 locales translatedvendor/bin/pint --dirtyandnpm run lintcleanNot in this PR
One decision left for review
Destinations are offered by capability (any connected account whose content type accepts video), which lets Threads and X in alongside the four networks originally scoped. They publish video through the existing publishers, so it costs nothing, but it is broader than the spec. Say the word and it is one line to restrict.