Skip to content

Add the repurpose module - #335

Open
paulocastellano wants to merge 48 commits into
mainfrom
repurpose-module
Open

Add the repurpose module#335
paulocastellano wants to merge 48 commits into
mainfrom
repurpose-module

Conversation

@paulocastellano

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.

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: 4181 passed, 1 skipped
  • 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
  • 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.
  • Approval mode: videos are replicated and published straight away.
  • 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 column and its unique were added by a second migration against a table
this same branch creates, so nothing has ever run the intermediate shape.
Declaring both in the create migration drops the index swap that only existed
to let MySQL replace a unique backing a foreign key. The unique keeps its
explicit name: MySQL caps identifiers at 64 characters and the generated one
is longer.

The index on post_platforms stays its own migration — that table has been in
production since January — and goes back to the plain schema helper. Building
it concurrently guarded against a write lock whose cost I never measured, and
paid for it with a driver branch, raw SQL and a build that can leave an
invalid index behind if it fails.
It holds machine-specific MCP wiring, including an absolute path to
whoever's checkout, so it is per-developer rather than shared. Ignored as a
directory like the other editor and tool folders above it.
The six entry points shared one rule set, so a rule written for the web form
silently governed the API and MCP too. They already disagree — the web dialog
only submits a source account, and updates make it optional — and any future
divergence had to be expressed as a patch on top of someone else's array.
Each request now spells out what it accepts.

The per-platform meta rules stay shared, because that one is not a style
preference: validated() strips any key without a rule, so a meta field
written out per surface goes missing on the surfaces that forget it, which is
how Discord, Pinterest and TikTok meta was lost through the API and MCP
before it was centralized. Only the re-keying from platforms.* to
destinations.* lives here; the rules themselves stay in PostPlatformMetaRules.
The fetchers compared raw strings against 'VIDEO', 'REELS', 'video' and
'PUBLISHED', which is four different vocabularies with nothing tying them to
the reference that defines them. They are enums now, one per node, each
carrying the link to the Meta page it came from.

The page size was 50 for no stated reason. It is not a quota lever — a page
costs the same single call whatever its size — so what it really bounds is
how far a poll can catch up after an outage, and on the stories edge how many
file lookups one poll can fire, since each row there costs its own call. 25
covers hours of a normal posting rate and caps that fan-out.

The stories edge also takes `since` and was not being given one, so every
poll re-listed the whole 24 hours of live stories.

Simplify the caption adapter while nearby: the overflow expression appeared
three times, the AI gate was six lines for one condition, and the shortener
took a limit its only caller could not vary.
mb_strrpos hands back int|false and the cut leaned on false > 0 being false to
mean "no space here". Str::beforeLast returns the subject when the separator is
absent, so the branch disappears. Str::limit with preserveWords looked like the
whole answer until it turned out to replace newlines with spaces, which would
flatten every multi-line caption — there is now a test for that.

The shrink loop derives the sanitized length from the overflow it already has,
so it goes through the same overflow() as everything else instead of reaching
into the sanitizer a second time.
The repurpose screens got their own tile because the app had a composable for
the theme but no component for the markup around it. The markup they got was
copied from the accounts grid, class for class, so there were two. It moves out
of the repurpose folder, the grid renders it, and the theme lookup the grid
kept only for that tile goes with it.
Every other index — posts, labels, signatures, webhooks, api keys — renders
the shared EmptyState. This one had a bare heading and a paragraph, so it read
as a different app: no dashed frame, no icon. The ready-made templates move
into its action slot, which is where the call to action belongs.
The loop reconstructed the sanitized length out of the overflow it had just
measured, which is why it read as arithmetic rather than intent. Asking the
sanitizer for the length directly costs nothing and the loop becomes what it
always was: while it does not fit, cut it to the length that would.

The hard floor of one character in the cut was a stall waiting to happen —
a caption already down to a single character would have been handed back
unchanged forever. The cut is now allowed to reach empty, and the caller's
own "at least one shorter" is what guarantees the loop ends.
The status file exported only a string union, so every call site wrote the
value by hand: an array literal to ask whether a repurpose is idle, and three
more comparisons in the template. It follows the shape the app already uses
for social account and webhook statuses now — a const object, the value type
derived from it, and the badge map checked against that type — so a renamed
status fails to compile instead of quietly matching nothing.

"Not disabled and not draft" was also the same question as canActivate asks,
written inside out; both read it from one computed now.

The toast that reports the first validation error was written twice, so it
joins usePageErrors, where the rest of the Inertia error handling lives.
The toast reported whichever error the bag happened to list first, which is
not how the app surfaces validation anywhere else: it reads the key it cares
about and renders it with InputError next to the thing that failed. So
usePageErrors goes back to what it was.

The per-platform meta errors were already arriving inline — the Discord and
Pinterest settings match the key by suffix precisely so they do not care
whether the prefix is platforms.* or destinations.* — and the toast was
repeating them. ChannelConfigurator already takes a contentTypeError per
channel and nothing was handing it one; now the destinations do, so a format
the network cannot carry is flagged on that network's card. What is left of
the toast is the generic "check the form", which says something the inline
messages do not.

The status card keeps a toast because the lifecycle actions fail on `status`
and `destinations`, neither of which is a field on the page, and it names
those two instead of taking whatever came first.
The backend knew which content type a destination should start on and passed
it by sorting the list so the answer landed at position zero. The page then
read [0] to get it back, twice. Nothing ever rendered that list — the channel
configurator builds its own options — so a labelled option set per account was
being shipped to recover one string.

It sends that string. The sort goes, the label mapping goes, the type for the
option shape goes, and the page reads a recommendation by name. There is a
test on it now, which there could not be while the answer was a position.

The Channel cast in the same computed also goes: with the error field typed
the object matches on its own, and the cast was only ever able to hide that.
It printed "- Brand: " with nothing after it whenever the workspace had no
name, handing the model an empty labelled field. The brand blocks are guarded
the way the humanizer guards them, and the platform label falls back the same
way.

The only length the model was given was the hard cap, so a model aiming at it
lands over it often enough, and every one of those costs a paid call whose
result we throw away for a plain truncation. It gets a target under the cap
now, and is told that landing under matters more than filling it.

Two rules were missing against what the caption actually is: keep the line
breaks that separate ideas — Instagram captions are written in blocks and
flattening them is a visible change — and the dash rule now states the zero
characters the house style asks for. The instruction to return the caption
unchanged when it already fits is gone; the caller only calls this when it
does not.
buildTitle measured and cut with strlen and substr. YouTube counts a title in
characters, and validateContentLength has already capped the content at 100 of
them, so the byte cut only ever fires on non-ASCII copy — where it trims
earlier than it needs to and can slice a multi-byte character in half. Four of
twelve offsets of an accented caption came out as invalid UTF-8, and that is
what reached the API.

Repurpose is what made this worth fixing here: it feeds Instagram captions
straight into Shorts, and those are rarely ASCII.

The byte arithmetic in the Bluesky publisher stays as it is — AT Protocol
facets index the text in bytes on purpose.
Replication went straight to the network, so watching a source meant trusting
it. A repurpose now carries a publishing mode: schedule each new video the
moment it is found, or leave it here as a draft to read before it goes out.
Nothing else about the post changes — same content, same media, same
destinations — it just waits.

The activity list needed a status for that: an item whose posts are sitting
in drafts is not published, and saying so was the only honest option. It is
terminal like the others, so a second pass over the same item does not
duplicate the drafts.

Available on all three surfaces. The column is its own migration rather than
folded into the create: the table already exists on installs that have run
this branch, and they should not have to reset to pick it up.
Four blocks each assigned inside their own condition to decide whether a field
was sent, re-parsing the enums the model already casts. UpdateWebhook answers
the same question with Arr::only and then special-cases only what needs it, so
this does too.

What is left is the one rule that is actually this action's: a repurpose aimed
at another account or another format has a back catalogue nobody asked for, so
the watermark moves. That is a named predicate now rather than a condition
repeated in two branches, and there is a test for the case it has to get right
— an update that resends the current source and format must not move it.
It went in as its own migration only because a checkout that had already run
this branch would otherwise need a reset to pick the column up. Nothing has
shipped, so the column belongs in the create.

The one checkout that had run it keeps the column and its data: the ledger row
for the removed migration is gone, so migrate:status matches a fresh run.
Changing a repurpose to an account and format the workspace already watches
reached the database and came back as SQLSTATE 23505 — a 500 the user could
do nothing with. Creating already refused it in words; updating did not. And
nothing on any surface stopped an account being listed as a destination of
itself, which would have republished a video onto the profile it came from.
The interface hid that one by excluding the source from the picker; the API
and MCP did not.

Two rule objects say it now, in the shape the app already uses for cross-field
checks, and each of the five entry points builds them from its own effective
values — the account and format a request will end up with, which on an update
may be the stored ones.

The constraint stays the truth, so the actions also translate a violation into
the same sentence: two requests can still pass validation at the same moment,
and the loser should read the same thing as everyone else rather than a stack
trace.

Both rules are covered directly, so a change that stops them failing is a red
test rather than a database error someone meets later.
The dialog listed every watchable account as a tile, which reads fine with two
and stops reading at ten — and self-hosted installs can connect as many of one
network as they like, so five Instagrams and five Facebooks is a real shape.

SearchableSelect already existed for the Discord channel picker. It takes a
slot for the option now, so the account still shows its logo and network
instead of collapsing to a line of text, and it is generic over the option so
the extra field stays typed. Discord passes no slot and renders as before.
Closed, the trigger was drawing the full platform tile — bordered, tinted,
rotated — plus the account name and the network under it. That treatment is
for a grid of cards to choose from, and inside a select it read as an oversized
badge in a box.

The slot now says which of the two it is drawing, so the closed state gets the
bare mark and the name on one line, and the open list keeps the tile and the
network label where they help you tell five Instagrams apart.
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