Skip to content

Add the repurpose module - #335

Merged
paulocastellano merged 115 commits into
mainfrom
repurpose-module
Sep 7, 2026
Merged

Add the repurpose module#335
paulocastellano merged 115 commits into
mainfrom
repurpose-module

Conversation

@paulocastellano

@paulocastellano paulocastellano commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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 with created_via = repurpose, and hands them to PublishPost. Retries, the calendar, analytics and post.published webhooks all come for free.

Content published through TryPost is never replicated: the source media id is matched against post_platforms.platform_post_id in 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.

Sources Instagram (direct and via Facebook) and Facebook Pages: the only networks that let us download the file
Source formats Reels, feed videos, Stories
Destinations any connected account whose content type accepts video, validated server-side
Trigger polling, watermarked at activation so nothing from the back catalogue is replicated

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 users per 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:

  • The scheduler ticks every five minutes but next_poll_at decides what is due, so the real cadence is REPURPOSE_POLL_INTERVAL_MINUTES (default 15) and can be dialled without a deploy.
  • A transient Graph error backs the source off for REPURPOSE_BACKOFF_MINUTES (default 60) instead of retrying next tick.
  • Only the endpoints a repurpose actually needs are called: an account watched for Reels alone never pays for the Stories request.

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

  • Instagram reports VIDEO for both a Reel and a feed video; media_product_type is the only thing telling them apart.
  • Instagram excludes Stories from /media; they live on their own edge and last 24 hours.
  • A Facebook Page splits the same three across /video_reels, /videos and /stories. /videos also lists Reels, so Reels are removed from the plain video list and a Page watched for both never replicates one twice.
  • The Facebook stories edge returns no downloadable file, only the media id, so the video behind each story is resolved in a second request.
  • Meta omits media_url for copyrighted audio; those are logged as skipped with the reason, not as failures.

One post per destination

A post carries a single content that 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() call ContentFitsPlatformLimits makes. 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.

  • Web: list, a dialog that only asks for the source account, then a three-tab edit page (configuration, activity, settings).
  • API: 11 endpoints plus GET /repurpose-templates; items paginated at the documented 15.
  • MCP: 11 tools, including one listing ready-made templates so an agent can propose a configuration.

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.

What happens What the repurpose does
Source account deleted Survives with its whole history — the FK is now nullOnDelete instead of cascadeOnDelete, which used to destroy the repurpose and every activity row silently. Pauses, and the page asks for a new source.
Source deactivated, token expired or disconnected Pauses, recording why.
Source recovers Resumes on its own, starting from now — replaying a two-day outage would flood the destinations with a backlog nobody asked for.
Destination deleted Pruned from the stored list; if it was the last one, the repurpose pauses.
Destination deactivated Skipped, as it already was, but now visible on the page instead of silent.
Destination disconnected Unchanged: the post is created and fails visibly, so the user can retry it.

repurposes.paused_reason records 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.

RepurposeAccountSync runs from SocialAccountObserver and 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() and VerifyWorkspaceConnections already 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:

  • A switched-off account no longer blocks editing. assertPublishable() demanded that every destination be active, and UpdateRepurpose runs 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 same is_active clause, 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.
  • A refreshed token promotes the account back to Connected. RefreshSocialToken only stamped last_verified_at, so a connection fixed by the hourly refresh stayed TokenExpired until 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, repurposes and repurpose_items, plus a nullable repurpose_item_id on posts so every generated post traces back to the video it came from. CreatedVia gains a repurpose case.

Test plan

  • php artisan test --compact --parallel: 4353 passed, 1 skipped
  • tests/Feature/Repurpose on MySQL: 175 passed, plus migrate:rollback and migrate both clean — the drop-change-recreate ordering the new migration is written for
  • Fetchers, polling, caption adaptation, item processing, actions, policy, web, API and MCP each covered, including a test proving two repurposes on one account cost one API call
  • Every account-failure path above has its own test, plus the races: two accounts of one repurpose dying in the same sweep, and a user pause that must never be auto-resumed
  • LocalizationParityTest green; all 16 locales translated
  • Walked the whole flow in the browser: template to dialog to draft to destinations to activation
  • vendor/bin/pint --dirty and npm run lint clean

Not in this PR

  • TikTok and YouTube as sources: neither API offers an official download, and a scraper would ship watermarked video against their terms. Both work as destinations.
  • Meta webhooks instead of polling.
  • Documenting the API and MCP surfaces on docs.trypost.it.

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.

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.
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.
The API pinned its own page size of 15 as a stable contract. It now reads
config('app.pagination.default') like the rest of the app, so the three list
endpoints move from 15 to 25 and follow that setting from here on. Clients read
meta.per_page, which every list response already carries.

With both sides on the same number, the builders the actions were exposing had
no reason to exist: the API calls the actions directly again, so the query and
its eager loads live in exactly one place.

CLAUDE.md updated — the API exception is gone rather than left describing
something the code no longer does.
watchesSomethingElse compared each incoming attribute against a default of the
current value, which reads as a puzzle and answered the question about the
caller's copy of the row rather than the locked one — so a concurrent update
could make it read the wrong "before".

Eloquent already answers this: fill the locked model, ask isDirty, then save.
The helper goes away and the check now runs against the row actually being
written.

Also drops the $dispatched counter from repurpose:poll, left behind when the
summary line it fed was removed, and reverts RefreshSocialToken promoting a
TokenExpired account back to Connected. A successful refresh proves the refresh
token is valid, not that publishing works — VerifyWorkspaceConnections promotes
after a real verify() call, and it should stay the only thing that does. That
change was made to shorten a repurpose's auto-resume, which is not a good enough
reason to loosen what "connected" means for the whole app.
The check compared a destination id against the source id, but lived in
rules() — which is assembled before anything is validated. So it read the
source straight off the raw payload, and needed is_string() to keep an array
from becoming a TypeError instead of a 422. The guard was covering for the
wrong placement.

It now runs in withValidator()/after(), where both sides have already passed
`uuid`, and the guard goes with it. The rule object becomes
SourceIsNotADestination with the same pair of entry points PostPlatformMetaRules
already uses: addErrors() for the request-driven surfaces and assert() for MCP,
which validates in one call and has no validator to add to.

repurpose() drops its instanceof and its nullable return. Route model binding
resolves the model or the request is never built, and pretending otherwise
spread ?-> through every caller — UpdatePostRequest reads $this->route('post')
directly for the same reason.
The repurpose tables have not shipped, so there is no deployed schema to
preserve — a second migration altering a column the branch itself added is only
noise for whoever reads it later. source_social_account_id is declared nullable
with nullOnDelete where it is created, and paused_reason sits next to status.

Also puts two front-end unions behind the const objects this codebase uses for
exactly that. The health banner had its four states inline as string literals,
and RepurposeItemList compared post state against 'failed' in three places while
typing it as a bare string — PostPlatformStatus already existed for that.
The module carried explanatory comments on nearly every decision, which belongs
in commit messages and the pull request rather than in the files. What stays is
the annotations the type checker needs: @PARAM, @return, @var and friends.
SourceIsFree had the same problem NotTheSourceAccount did: built while rules()
is assembled, so it read the format off the raw payload and carried a
Str::isUuid guard to survive whatever the client sent. Both checks now run in
withValidator()/after() for the request surfaces and after validate() for MCP,
where every value has already passed its field rules.

That removes the last of the defensive reads — sourceAccountId(), sourceFormat()
and the (string) casts they needed — from all five request classes. Values are
read with SourceFormat::from() and an explicit default rather than tryFrom()
with a fallback, because at that point an invalid one is impossible.

CreateRepurpose gets the same treatment: it only ever receives a validated
array, so casting each value again was guarding against something that cannot
arrive.
defaultContentTypeFor() ends in ContentType::defaultFor(), which returns self,
so it never returns null — the ?? videoContentTypesFor(...)[0] ?? null chain
behind it in the controller could not be reached, and neither could the filter()
cleaning up after it. The return type said ?ContentType and was wrong.

Inside the enum, the remaining first-element read becomes Arr::first(), which
says what it means without depending on the array being zero-indexed.
queueNewMedia filtered with a four-clause closure inlined into array_filter,
then looped over the result with the whole per-item decision nested inside. It
now filters in two named steps and hands each entry to queue(), which reads as
the four outcomes it actually has.

The watermark comparison moves onto SourceMedia as isNewerThan(), where the
question belongs, and earliestWatermark() returns ?CarbonInterface instead of
mixed.

reschedule() and markPolled() lose their minutes argument — every caller passed
interval() — and the polling failure logs at error level, so it reaches
Nightwatch rather than sitting at warning.
truncate() guessed a length by scaling the current one against how much the
sanitized text had to shrink, then looped until the guess happened to fit —
which needed a floor of length-1 so it could not stall. It now binary-searches
for the longest prefix that fits, which is bounded and says what it looks for,
and falls back to that prefix if cutting at a word boundary would somehow push
it over again.

The shortener's return reads as two conditions instead of a three-part ternary.

In the Facebook fetcher the story loop becomes a filter and a map over named
predicates, the reel de-duplication drops a guard that array_filter handles on
its own, and the repeated created-time parse moves onto MetaSourceFetcher, where
both fetchers reach it.
The observer listed the three columns a repurpose cares about, which is not the
observer's knowledge to hold — adding a fourth would mean editing a file that
otherwise knows nothing about the module. RepurposeAccountSync names them and
checks them itself, so the hook is a single delegation.
fetch() reads the reels edge whenever videos are wanted, because /videos lists
reels too and nothing distinguishes them. That subtraction sat inline between
the reads and the return, where it looked like part of assembling the result
rather than a correction to one of its parts. withoutReels() says what it is,
and fetch() is now three reads and a return.
Whether a repurpose uses an account as its source or one of its destinations is
the repurpose's own knowledge, and it was spelled out as a nested closure in two
places — the accounts controller and RepurposeAccountSync — with the destination
half duplicated between them.

dependsOn() and hasDestination() live on the model, where they can be tested
directly rather than only through whatever calls them.
SourceMedia is a readonly value object, not a service — it is the contract the
fetchers translate each network's response into, so the polling job never has to
know which one a video came from. It sat in app/Services/Repurpose only because
several other DTOs sit under app/Services too, which is drift rather than a
convention: app/DataTransferObjects already existed for exactly this.

That folder is now app/Dto, matching how the codebase already writes acronyms —
App\Ai\Agents and App\Mcp\Tools, not AI or MCP.

The fetchers referenced SourceMedia without importing it, since it used to share
their namespace; they import it now.
isNewerThan() returned true when either side was null, so a video the API gave
no timestamp for was "newer than" everything — the name asserted something the
method never established. predates() states the opposite and only says yes when
both dates exist and the comparison holds, which is the same filter read from
the side it can prove.
The binary search ran over characters, so it landed mid-word and needed
cutAtWord to walk back to the last space — two methods and a fallback for when
walking back left nothing.

Searching over words lands on a boundary by construction, and explode/implode on
a single space is lossless, so newlines and runs of spaces survive. The search
itself is now a small helper taking "how many" and "how to build that many",
which the word pass and the character pass both use — the second only runs when
not even one word fits, and a test covers that.
The binary search needed a callable to describe how to build a candidate, a
helper to run the search, and a second pass with a different builder — three
moving parts to express "shorten it until it fits".

Dropping the last word until it fits says that directly. A caption is a few
hundred words at most and this runs once per replicated post in a queued job,
so the extra sanitize calls cost nothing worth this much indirection. The
character loop below it is the same shape, and only runs when a single word is
longer than the whole limit.
truncate() rebuilt the caption from an exploded array to drop its last word.
Str::beforeLast does that on the string itself, and Str::limit makes the final
cut when a single word is longer than the whole limit — no explode, implode or
array_pop. The str_contains guard is load-bearing: beforeLast returns the whole
subject when there is no separator, so without it a caption with no spaces loops
forever. Two tests cover that and the survival of newlines and repeated spaces.

shorten() wrapped its call and its usage record in a try/catch that logged and
returned null. rescue() is the helper for exactly that, and it is already used
elsewhere in this module; failures now reach the exception handler instead of
sitting at warning level. The model call moved into ask(), where $user is no
longer nullable because the caller has already checked it, and filled() covers
the empty and null results in one read.
…activated

A destination could be stored without the meta its network needs — a Pinterest
board, a TikTok privacy level, a Discord channel. The gate that checks it only
ran on activation, or on an update to an already-active repurpose, so a draft
accepted the destination silently and the user had no way to know until later.

A repurpose publishes without anyone reviewing the post first, which is exactly
why the post editor validates this before scheduling. The same check now runs on
save across all five surfaces, reporting on destinations.N.meta.<field> so the
error lands on the control that is missing rather than on the form.
Templates offered two ready-made configurations from the empty state, which
made sense when creating a repurpose meant filling a long form. The flow now
asks only for the source account and takes the user to a page where each
destination is configured on its own, so a template saves nothing and pins
choices the user has to revisit anyway.

Gone from the empty state, the create dialog's locked-platform filter, the MCP
tool, the API endpoint, the shared class and sixteen locale files.

The source formats a repurpose can watch travelled in that same endpoint and are
not a template — an integration still needs to know reel, video and story exist.
They keep their own endpoint and MCP tool.
The page header already carries the create button, so the one inside the empty
state was a second copy of the same action a few hundred pixels below it. Its
description still read "pick a starting point below", which pointed at the
templates that are gone.
Saving and publishing were the same gate here: a destination missing the
meta its network needs was rejected on every save, so the edit page could
only answer with a generic toast and a collapsed settings card giving no
hint of which field was wrong.

Split the two, the way UpdatePostRequest already does for a post: required
meta is enforced only once a repurpose is Active, which is the state that
publishes without anyone reviewing the post first. A draft, paused or
disabled one saves incomplete; activating or resuming runs the same check
it always did. The rule lives in DestinationMetaRules::enforcedFor() so the
web, API and MCP surfaces cannot drift apart.

The page follows from that: changes autosave on a debounce with a
Saving/Saved indicator instead of a Save button, every selected destination
missing its meta carries the red badge and tooltip the post editor gives a
non-compliant channel, and Activate is always on screen, disabled with the
list of what is missing rather than hidden. TikTok's privacy level was the
one required field with no inline error; it has one now, like Pinterest's
board and Discord's channel.

Recovered tests/Feature/Mcp/RepurposeToolTest.php, which was committed
without its opening <?php tag. Pest reports "No tests found" and moves on,
so its 19 tests had never run.
PollRepurposeSource could never be dispatched. Laravel probes a queued job
with method_exists() for a set of hook names and calls whichever it finds
from its own scope, so the job's private queue() helper was invoked by
Dispatcher::dispatchToQueue() and its private backoff() by
Queue::createPayload(), each fataling with "Call to private method". Every
real repurpose:poll run died there, and the failed dispatch kept the
ShouldBeUnique lock for its full ten minutes, so the next run reported
success while pushing nothing.

Renamed both to say what they do here: recordMedia() files a source video
as an item, backoffMinutes() and intervalMinutes() answer how long until
the next poll.

Nothing caught this. The job's tests either call handle() directly or fake
the bus, and both skip the two checks; the sync connection used by the
suite never serializes a job either. A unit test now reflects over every
ShouldQueue class under app/Jobs and fails when a non-public method takes
one of the sixteen names the framework reserves. PollingTest also dispatches
through a real queue connection so the Dispatcher path is exercised at all.
The reflection scan over app/Jobs asserted the shape of the code rather
than any behaviour, and it was unnecessary: SyncQueue::executeJob() builds
a payload, so the sync connection the suite already runs on passes through
both places the framework calls a job's hooks — Dispatcher::dispatchToQueue()
for queue() and Queue::createPayload() for backoff().

So repurpose:poll now runs without any fake and asserts what it should have
all along: the source was polled and an item exists. Restoring either name
fails it with the original "Call to private method". The Queue::fake()
version it replaces caught only the first, since the fake never builds a
payload.
Only the meta keys were shared. The rest of the payload — the source account
with its supported-platform scope, the format and publish mode, the
destination account and content type with their platform checks — was copied
verbatim into five request classes: the web and API rule bodies were byte for
byte identical, and the MCP ones differed only in how they reach the workspace
id.

That is the same duplication the meta rules were centralized to escape, and
the same failure mode: validated() drops every key without a rule, so a field
added to one entry point is silently discarded by the others.

RepurposeRules now owns the payload. settings() covers what the automation
watches and how it publishes, taking whether the source is required, since
that is the only thing create and update disagree on; destinations() covers
where it sends, meta included. Messages and attributes move with them. The
resulting rule map is key for key what each entry point produced before.

The MCP request classes also dropped two parameters their callers passed and
their bodies never read.
A crashed draft run passed for a finished one. The short-circuit that
returned Drafted as soon as any post existed could only be reached with a
non-terminal item that already had posts, which is exactly what a run that
died mid-loop leaves behind: a complete run ends Drafted, and Drafted is
terminal. So it never protected a finished set, only sealed a partial one,
and the retry that would have repaired it returned early instead. It is
gone, and draft mode now rebuilds the way publish mode always did. An
exhausted run says it failed rather than drafted, keeping the posts it
managed to create when they are the only thing the download produced.

Autosave failed in silence. Every other error already had a home — meta
under its platform panel, content type under the variant picker, the source
under its picker — but the one the update action raises for a repurpose
left with no usable destination had none, so unchecking every destination
diverged the page from the server without a word. It renders through
InputError under the destination picker now, from the message the backend
sends.

Splitting assertDestinationsPublishable stops one rule from speaking two
ways: an update only has to leave a usable destination, while activating
and resuming also demand the meta each network needs. Both are stated at
each call site rather than behind a name that hid which check ran where.

The caption shortener is asked once per caption and limit instead of once
per destination, so networks that share a limit stop paying twice for the
same answer; whether the result fits stays per-platform, since each one
sanitizes differently before sending. And a source whose token expired now
says so in the picker instead of only failing at Activate.

On the index: delete is gone — it belongs to the page that owns the
repurpose — the flow reads from the left edge rather than floating in the
middle of its column, and the source column went with it, since the flow's
first icon is the source. Replicated is centred, last checked is right
aligned and muted. pt-BR calls the feature Repost.
ConfirmDeleteModal defaults its title, description and buttons to English
strings and only translates the body it owns, so a caller that passes
nothing gets half a dialog in each language. Every other caller — MCP,
posts, signatures — passes the four props translated; the repurpose page
was the one that did not, and read "Are you sure?" above "Esta ação não
pode ser desfeita."

pt-BR now calls the feature repost everywhere it names it: the button, the
empty state, the create dialog, both delete strings and the six transition
errors. English keeps repurpose, which is what the module is called in the
code.
The module replicates videos today, but text posts are coming and the copy
named the medium in nine places per locale: the page description, the
publishing card and its two hints, the source card, the empty index, both
status hints and the empty activity list. All sixteen locales now describe
what the automation does without naming what it carries, each in its own
term rather than a literal translation of the English one.

Four mentions stay because they are true and not a promise: the Videos
source format, the note that only Instagram and Facebook let us download
the file, the failed download, and the format that cannot carry a video.

Also drops repurposes.show.description, which every locale carried and
nothing rendered — the sentence under the title comes from summary — and
the rationale docblock left on DestinationMetaRules::enforcedFor.
@paulocastellano
paulocastellano merged commit c85f3e9 into main Sep 7, 2026
5 checks passed
@paulocastellano
paulocastellano deleted the repurpose-module branch September 7, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant