diff --git a/resources/js/pages/repurposes/Index.vue b/resources/js/pages/repurposes/Index.vue
index d13a67a0e..053bd5e53 100644
--- a/resources/js/pages/repurposes/Index.vue
+++ b/resources/js/pages/repurposes/Index.vue
@@ -55,7 +55,9 @@ const destinationNodes = (repurpose: Repurpose): FlowNode[] =>
repurpose.destinations.flatMap((destination) => {
const account = props.destinationAccounts.find((item) => item.id === destination.social_account_id);
- return account ? [{ platform: account.platform, label: account.display_name }] : [];
+ return account
+ ? [{ platform: account.platform, label: account.display_name, username: account.username }]
+ : [];
});
const handleDelete = (repurpose: Repurpose) => {
@@ -120,6 +122,7 @@ const handleDelete = (repurpose: Repurpose) => {
:source="{
platform: repurpose.source_account?.platform ?? '',
label: repurpose.source_account?.display_name,
+ username: repurpose.source_account?.username,
}"
:destinations="destinationNodes(repurpose)"
size="sm"
diff --git a/resources/js/pages/repurposes/Show.vue b/resources/js/pages/repurposes/Show.vue
index 3bc032273..0221ce2e3 100644
--- a/resources/js/pages/repurposes/Show.vue
+++ b/resources/js/pages/repurposes/Show.vue
@@ -49,6 +49,7 @@ const currentFormatLabel = computed(
const sourceNode = computed(() => ({
platform: props.repurpose.source_account?.platform ?? '',
label: props.repurpose.source_account?.display_name,
+ username: props.repurpose.source_account?.username,
format: currentFormatLabel.value,
}));
@@ -64,6 +65,7 @@ const destinationNodes = computed(() =>
{
platform: account.platform,
label: account.display_name,
+ username: account.username,
format: props.destinationFormats[account.id]?.find(
(format) => format.value === destination.content_type,
)?.label,
diff --git a/resources/js/types/repurpose.ts b/resources/js/types/repurpose.ts
index b120c39af..d3df3900a 100644
--- a/resources/js/types/repurpose.ts
+++ b/resources/js/types/repurpose.ts
@@ -11,6 +11,7 @@ export interface SourceFormatOption {
export interface FlowNode {
platform: string;
label?: string | null;
+ username?: string | null;
format?: string | null;
}
From a6c1ece2645c7afc9fc84418833c2212f04d3c27 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sat, 5 Sep 2026 17:17:59 -0300
Subject: [PATCH 010/114] Drop the repurpose unique in an order MySQL accepts,
and finish the plan
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.
---
README.md | 1 +
..._add_source_format_to_repurposes_table.php | 25 +++-
tests/Browser/RepurposeTest.php | 112 ++++++++++++++++++
3 files changed, 133 insertions(+), 5 deletions(-)
create mode 100644 tests/Browser/RepurposeTest.php
diff --git a/README.md b/README.md
index 77110b7ac..05b754830 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,7 @@
| **AI generate & review** | Draft from a prompt, get inline feedback before you publish. |
| **AI carousel builder** | Prompt to a multi-slide carousel with images, on-brand. |
| **Brand profile** | Tone, voice, language, and colors applied to every AI call. |
+| **Repurpose** | Auto-replicate the videos you post outside TryPost to your other networks. |
| **Asset library** | Reusable workspace media, plus Unsplash and Giphy search built in. |
| **Signatures & labels** | Reusable hashtag and CTA blocks, color-coded post tags. |
| **Team collaboration** | Owner / Admin / Member roles, comments with @mentions on drafts. |
diff --git a/database/migrations/2026_09_05_193803_add_source_format_to_repurposes_table.php b/database/migrations/2026_09_05_193803_add_source_format_to_repurposes_table.php
index 8fbbcc5e1..b5c943e94 100644
--- a/database/migrations/2026_09_05_193803_add_source_format_to_repurposes_table.php
+++ b/database/migrations/2026_09_05_193803_add_source_format_to_repurposes_table.php
@@ -15,20 +15,35 @@ public function up(): void
$table->string('source_format')->default(SourceFormat::Reel->value)->after('source_social_account_id');
});
- // A repurpose watches one format, so replicating both Reels and feed
- // videos from one account takes two of them. Polling groups by account,
- // so this costs no extra calls against Meta's quota.
+ // A repurpose watches one format, so replicating both Reels and Stories
+ // from one account takes two of them. Polling groups by account, so
+ // dropping the unique costs no extra calls against Meta's quota.
+ //
+ // MySQL refuses to drop the only index backing the workspace_id foreign
+ // key (SQLSTATE 1553), so the replacement goes in before the unique
+ // comes out, in its own statement.
+ Schema::table('repurposes', function (Blueprint $table) {
+ $table->index(['workspace_id', 'source_social_account_id'], 'repurposes_workspace_source_index');
+ });
+
Schema::table('repurposes', function (Blueprint $table) {
$table->dropUnique(['workspace_id', 'source_social_account_id']);
- $table->index(['workspace_id', 'source_social_account_id']);
});
}
public function down(): void
{
+ // Same constraint in reverse: the unique has to exist before the plain
+ // index backing the foreign key can go.
Schema::table('repurposes', function (Blueprint $table) {
- $table->dropIndex(['workspace_id', 'source_social_account_id']);
$table->unique(['workspace_id', 'source_social_account_id']);
+ });
+
+ Schema::table('repurposes', function (Blueprint $table) {
+ $table->dropIndex('repurposes_workspace_source_index');
+ });
+
+ Schema::table('repurposes', function (Blueprint $table) {
$table->dropColumn('source_format');
});
}
diff --git a/tests/Browser/RepurposeTest.php b/tests/Browser/RepurposeTest.php
new file mode 100644
index 000000000..02710796b
--- /dev/null
+++ b/tests/Browser/RepurposeTest.php
@@ -0,0 +1,112 @@
+script(<< {
+ const sel = '[data-testid="{$testId}"]';
+ for (let i = 0; i < 100; i++) {
+ const el = document.querySelector(sel);
+ if (el && el.getBoundingClientRect().height > 0) return;
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ })();
+ JS);
+}
+
+function repurposeOwnerWithAccounts(): array
+{
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+
+ $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]);
+ $destination = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::TikTok]);
+
+ return [$user->fresh(), $workspace, $source, $destination];
+}
+
+test('the empty state offers the ready-made templates', function () {
+ [$user] = repurposeOwnerWithAccounts();
+
+ $this->actingAs($user);
+
+ $page = visit(route('app.repurposes.index'));
+
+ waitForRepurposeTestId($page, 'use-template-instagram_everywhere');
+
+ $page->assertRoute('app.repurposes.index')
+ ->assertVisible('@use-template-instagram_everywhere')
+ ->assertVisible('@use-template-facebook_everywhere')
+ ->assertVisible('@create-repurpose-button')
+ ->assertNoJavaScriptErrors();
+});
+
+test('using a template opens the dialog with only the matching source account', function () {
+ [$user, , $source] = repurposeOwnerWithAccounts();
+
+ $this->actingAs($user);
+
+ $page = visit(route('app.repurposes.index'));
+
+ waitForRepurposeTestId($page, 'use-template-instagram_everywhere');
+
+ $page->click('@use-template-instagram_everywhere');
+
+ waitForRepurposeTestId($page, 'create-repurpose-dialog');
+
+ $page->assertVisible('@create-repurpose-dialog')
+ ->assertVisible("@source-account-{$source->id}")
+ ->assertVisible('@create-repurpose-submit')
+ ->assertNoJavaScriptErrors();
+});
+
+test('the edit page shows the watched format, the destinations and the settings tab', function () {
+ [$user, $workspace, $source, $destination] = repurposeOwnerWithAccounts();
+
+ $repurpose = Repurpose::factory()->create([
+ 'workspace_id' => $workspace->id,
+ 'user_id' => $user->id,
+ 'source_social_account_id' => $source->id,
+ 'source_format' => SourceFormat::Reel,
+ 'destinations' => [[
+ 'social_account_id' => $destination->id,
+ 'content_type' => ContentType::TikTokVideo->value,
+ 'meta' => [],
+ ]],
+ ]);
+
+ $this->actingAs($user);
+
+ $page = visit(route('app.repurposes.show', $repurpose));
+
+ waitForRepurposeTestId($page, 'source-format-select');
+
+ $page->assertRoute('app.repurposes.show', ['repurpose' => $repurpose->id])
+ ->assertVisible('@repurpose-summary')
+ ->assertVisible('@repurpose-source-card')
+ ->assertVisible('@source-format-select')
+ ->assertVisible('@destination-picker')
+ ->assertVisible("@destination-{$destination->id}")
+ ->assertVisible('@tab-settings')
+ ->assertNoJavaScriptErrors();
+});
From 17e0aa502850b0af2c75da4caa6cbf91ad8c6ec3 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sat, 5 Sep 2026 17:34:39 -0300
Subject: [PATCH 011/114] Configure repurpose destinations with the post
editor's own components
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.
---
app/Actions/Repurpose/ActivateRepurpose.php | 29 ++++
.../Controllers/App/RepurposeController.php | 43 ++++++
app/Support/PostPlatformMetaRules.php | 13 ++
.../repurpose/DestinationPicker.vue | 130 ------------------
resources/js/pages/repurposes/Show.vue | 77 ++++++++++-
resources/js/types/repurpose-status.ts | 38 ++---
tests/Browser/RepurposeTest.php | 2 -
tests/Feature/Repurpose/ActionsTest.php | 59 ++++++++
8 files changed, 236 insertions(+), 155 deletions(-)
delete mode 100644 resources/js/components/repurpose/DestinationPicker.vue
diff --git a/app/Actions/Repurpose/ActivateRepurpose.php b/app/Actions/Repurpose/ActivateRepurpose.php
index d4442ed70..2ca328200 100644
--- a/app/Actions/Repurpose/ActivateRepurpose.php
+++ b/app/Actions/Repurpose/ActivateRepurpose.php
@@ -6,6 +6,8 @@
use App\Enums\Repurpose\Status;
use App\Models\Repurpose;
+use App\Models\SocialAccount;
+use App\Support\PostPlatformMetaRules;
use Illuminate\Validation\ValidationException;
class ActivateRepurpose
@@ -24,6 +26,8 @@ public static function execute(Repurpose $repurpose): Repurpose
]);
}
+ self::assertDestinationsCanPublish($repurpose);
+
$repurpose->update([
'status' => Status::Active,
'activated_at' => now(),
@@ -33,4 +37,29 @@ public static function execute(Repurpose $repurpose): Repurpose
return $repurpose->fresh();
}
+
+ /**
+ * TikTok, Pinterest and Discord each need a piece of meta before anything
+ * can reach them. Without this an active repurpose would look healthy and
+ * turn every replicated video into a failed post.
+ */
+ private static function assertDestinationsCanPublish(Repurpose $repurpose): void
+ {
+ foreach ($repurpose->destinations as $destination) {
+ $account = SocialAccount::find(data_get($destination, 'social_account_id'));
+
+ $violation = PostPlatformMetaRules::missingRequiredMeta(
+ $account?->platform,
+ data_get($destination, 'meta'),
+ );
+
+ if ($violation === null) {
+ continue;
+ }
+
+ throw ValidationException::withMessages([
+ 'destinations' => $violation[1],
+ ]);
+ }
+ }
}
diff --git a/app/Http/Controllers/App/RepurposeController.php b/app/Http/Controllers/App/RepurposeController.php
index 41779d4a7..46e93844a 100644
--- a/app/Http/Controllers/App/RepurposeController.php
+++ b/app/Http/Controllers/App/RepurposeController.php
@@ -13,16 +13,22 @@
use App\Actions\Repurpose\PauseRepurpose;
use App\Actions\Repurpose\ResumeRepurpose;
use App\Actions\Repurpose\UpdateRepurpose;
+use App\Actions\SocialAccount\ListPinterestBoards;
use App\Enums\PostPlatform\ContentType;
use App\Enums\Repurpose\SourceFormat;
+use App\Enums\SocialAccount\Platform;
use App\Http\Requests\App\Repurpose\StoreRepurposeRequest;
use App\Http\Requests\App\Repurpose\UpdateRepurposeRequest;
+use App\Http\Resources\App\PlatformConfigResource;
use App\Http\Resources\App\SocialAccountResource;
use App\Models\Repurpose;
+use App\Models\SocialAccount;
use App\Services\Repurpose\SourceFetcherFactory;
+use App\Services\Social\TikTokCreatorInfo;
use App\Support\Repurpose\Templates;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
+use Illuminate\Support\Collection;
use Inertia\Inertia;
use Inertia\Response;
@@ -55,6 +61,7 @@ public function show(Request $request, Repurpose $repurpose): Response
'items' => Inertia::scroll(fn () => ListRepurposeItems::execute($repurpose)),
'sourceFormats' => $this->sourceFormats($repurpose),
'destinationFormats' => $this->destinationFormats($request, $repurpose->source_format),
+ ...$this->platformSettingsProps($this->connectedAccounts($request)),
]);
}
@@ -169,6 +176,42 @@ private function destinationFormats(Request $request, SourceFormat $sourceFormat
return $formats;
}
+ /**
+ * The same per-network settings data the post editor loads, so a repurpose
+ * destination configures TikTok privacy, a Pinterest board or a Discord
+ * channel through the very components the editor uses.
+ *
+ * @param Collection $accounts
+ * @return array
+ */
+ private function platformSettingsProps($accounts): array
+ {
+ return [
+ 'platformConfigs' => $accounts->mapWithKeys(fn ($account) => [
+ $account->id => new PlatformConfigResource($account),
+ ]),
+ 'pinterestBoards' => $accounts
+ ->where('platform', Platform::Pinterest)
+ ->mapWithKeys(fn ($account) => [
+ $account->id => rescue(
+ fn () => ListPinterestBoards::execute($account),
+ ['boards' => [], 'truncated' => false],
+ report: false,
+ ),
+ ]),
+ 'tiktokCreatorInfos' => $accounts
+ ->where('platform', Platform::TikTok)
+ ->mapWithKeys(fn ($account) => [
+ $account->id => rescue(
+ fn () => app(TikTokCreatorInfo::class)->fetch($account),
+ null,
+ report: false,
+ ),
+ ])
+ ->filter(),
+ ];
+ }
+
/**
* Only networks TryPost can both list and download from can be a source.
*/
diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php
index 15cc71046..70025cdcf 100644
--- a/app/Support/PostPlatformMetaRules.php
+++ b/app/Support/PostPlatformMetaRules.php
@@ -160,6 +160,19 @@ public static function assertStoredPostPublishable(Post $post): void
*
* @return array{0: string, 1: string}|null [field, message]
*/
+ /**
+ * The meta a platform needs before anything can be published to it, or null
+ * when it needs none. Callers that hold a platform and its meta outside a
+ * post (a repurpose destination, for one) ask here rather than repeating the
+ * list, so a new requirement lands in one place.
+ *
+ * @return array{0: string, 1: string}|null [field, message]
+ */
+ public static function missingRequiredMeta(?Platform $platform, mixed $meta): ?array
+ {
+ return self::requiredMetaViolation($platform, $meta);
+ }
+
private static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array
{
return match (true) {
diff --git a/resources/js/components/repurpose/DestinationPicker.vue b/resources/js/components/repurpose/DestinationPicker.vue
deleted file mode 100644
index cc0dc707c..000000000
--- a/resources/js/components/repurpose/DestinationPicker.vue
+++ /dev/null
@@ -1,130 +0,0 @@
-
-
-
-
From ae8e8705e2318805d7be1192e75ff83f806bf37e Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sat, 5 Sep 2026 17:48:06 -0300
Subject: [PATCH 013/114] Lead the repurpose page with what it actually does
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.
---
lang/ar/common.php | 1 +
lang/ar/repurposes.php | 3 +-
lang/de/common.php | 1 +
lang/de/repurposes.php | 3 +-
lang/el/common.php | 1 +
lang/el/repurposes.php | 3 +-
lang/en/common.php | 1 +
lang/en/repurposes.php | 3 +-
lang/es/common.php | 1 +
lang/es/repurposes.php | 3 +-
lang/fr/common.php | 1 +
lang/fr/repurposes.php | 3 +-
lang/it/common.php | 1 +
lang/it/repurposes.php | 3 +-
lang/ja/common.php | 1 +
lang/ja/repurposes.php | 3 +-
lang/ko/common.php | 1 +
lang/ko/repurposes.php | 3 +-
lang/nl/common.php | 1 +
lang/nl/repurposes.php | 3 +-
lang/pl/common.php | 1 +
lang/pl/repurposes.php | 3 +-
lang/pt-BR/common.php | 1 +
lang/pt-BR/repurposes.php | 3 +-
lang/ru/common.php | 1 +
lang/ru/repurposes.php | 3 +-
lang/tr/common.php | 1 +
lang/tr/repurposes.php | 3 +-
lang/uk/common.php | 1 +
lang/uk/repurposes.php | 3 +-
lang/zh/common.php | 1 +
lang/zh/repurposes.php | 3 +-
resources/js/components/AppSidebar.vue | 1 +
resources/js/components/NavMain.vue | 8 ++
.../repurpose/RepurposeStatusCard.vue | 9 +--
.../components/repurpose/RepurposeSummary.vue | 46 +++++++-----
resources/js/pages/repurposes/Show.vue | 75 +++++++------------
resources/js/types/index.d.ts | 1 +
38 files changed, 112 insertions(+), 92 deletions(-)
diff --git a/lang/ar/common.php b/lang/ar/common.php
index 0061b0874..99b71618c 100644
--- a/lang/ar/common.php
+++ b/lang/ar/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'تجريبي',
'back' => 'رجوع',
diff --git a/lang/ar/repurposes.php b/lang/ar/repurposes.php
index 0c60fde3b..a1dc1f03e 100644
--- a/lang/ar/repurposes.php
+++ b/lang/ar/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => 'كل :format جديد تنشره على :source يُعاد نشره على :destinations.',
+ 'no_destinations' => 'كل :format جديد تنشره على :source ما زال بانتظار وجهة.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => 'الوجهات',
- 'description' => 'يُنشر كل فيديو جديد من المصدر على كل حساب تختاره هنا.',
+ 'description' => 'اختر الحسابات التي ستستقبله. ينشر كل حساب بالصيغة التي تحددها.',
'hint' => 'يُعدَّل النص لكل شبكة فقط عندما يتجاوز حد تلك الشبكة.',
'none_available' => 'لا يوجد حساب آخر متصل في مساحة العمل هذه بعد.',
'save' => 'حفظ الوجهات',
diff --git a/lang/de/common.php b/lang/de/common.php
index e62b1598c..536d37610 100644
--- a/lang/de/common.php
+++ b/lang/de/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'Beta',
'back' => 'Zurück',
diff --git a/lang/de/repurposes.php b/lang/de/repurposes.php
index c35909be3..d3619d482 100644
--- a/lang/de/repurposes.php
+++ b/lang/de/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => 'Jedes neue :format, das du auf :source postest, wird auf :destinations erneut veröffentlicht.',
+ 'no_destinations' => 'Jedes neue :format auf :source wartet noch auf ein Ziel.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => 'Ziele',
- 'description' => 'Jedes neue Video der Quelle wird auf jedem hier gewählten Konto veröffentlicht.',
+ 'description' => 'Wähle die Konten, die es erhalten. Jedes veröffentlicht im Format deiner Wahl.',
'hint' => 'Der Text wird nur dann pro Netzwerk angepasst, wenn er dessen Limit überschreitet.',
'none_available' => 'In diesem Workspace ist noch kein weiteres Konto verbunden.',
'save' => 'Ziele speichern',
diff --git a/lang/el/common.php b/lang/el/common.php
index ca899b05c..fa36b43bf 100644
--- a/lang/el/common.php
+++ b/lang/el/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'Βήτα',
'back' => 'Πίσω',
diff --git a/lang/el/repurposes.php b/lang/el/repurposes.php
index 5797cff1a..7abbf9dfb 100644
--- a/lang/el/repurposes.php
+++ b/lang/el/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => 'Κάθε νέο :format που ανεβάζεις στο :source αναδημοσιεύεται σε :destinations.',
+ 'no_destinations' => 'Κάθε νέο :format στο :source περιμένει ακόμη προορισμό.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => 'Προορισμοί',
- 'description' => 'Κάθε νέο βίντεο της πηγής δημοσιεύεται σε κάθε λογαριασμό που επιλέγεις εδώ.',
+ 'description' => 'Διάλεξε τους λογαριασμούς που θα το λάβουν. Καθένας δημοσιεύει στη μορφή που ορίζεις.',
'hint' => 'Η λεζάντα προσαρμόζεται ανά δίκτυο μόνο όταν ξεπερνά το όριο εκείνου του δικτύου.',
'none_available' => 'Δεν υπάρχει άλλος συνδεδεμένος λογαριασμός σε αυτόν τον χώρο εργασίας.',
'save' => 'Αποθήκευση προορισμών',
diff --git a/lang/en/common.php b/lang/en/common.php
index c665762d5..21ec7f902 100644
--- a/lang/en/common.php
+++ b/lang/en/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'Beta',
'back' => 'Back',
diff --git a/lang/en/repurposes.php b/lang/en/repurposes.php
index abb3d8805..17e170827 100644
--- a/lang/en/repurposes.php
+++ b/lang/en/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => 'Every new :format you post on :source is republished to :destinations.',
+ 'no_destinations' => 'Every new :format you post on :source is waiting for a destination.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => 'Destinations',
- 'description' => 'Every new video from the source is published to each account you select here.',
+ 'description' => 'Pick the accounts that receive it. Each one publishes in the format you choose.',
'hint' => 'Captions are adapted per network only when they exceed that network\'s limit.',
'none_available' => 'No other account is connected in this workspace yet.',
'save' => 'Save destinations',
diff --git a/lang/es/common.php b/lang/es/common.php
index 572e05f2a..f0d0ab295 100644
--- a/lang/es/common.php
+++ b/lang/es/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'Beta',
'back' => 'Volver',
diff --git a/lang/es/repurposes.php b/lang/es/repurposes.php
index abc0b59b0..7c2a209a7 100644
--- a/lang/es/repurposes.php
+++ b/lang/es/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => 'Cada nuevo :format que publiques en :source se republica en :destinations.',
+ 'no_destinations' => 'Cada nuevo :format que publiques en :source está esperando un destino.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => 'Destinos',
- 'description' => 'Cada vídeo nuevo del origen se publica en todas las cuentas que selecciones aquí.',
+ 'description' => 'Elige las cuentas que lo recibirán. Cada una publica en el formato que elijas.',
'hint' => 'El texto solo se adapta por red cuando supera el límite de esa red.',
'none_available' => 'No hay ninguna otra cuenta conectada en este espacio de trabajo.',
'save' => 'Guardar destinos',
diff --git a/lang/fr/common.php b/lang/fr/common.php
index 2fb056b1f..acec0fcb4 100644
--- a/lang/fr/common.php
+++ b/lang/fr/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'Bêta',
'back' => 'Retour',
diff --git a/lang/fr/repurposes.php b/lang/fr/repurposes.php
index b74cb5247..69334bb9c 100644
--- a/lang/fr/repurposes.php
+++ b/lang/fr/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => 'Chaque nouveau :format publié sur :source est republié sur :destinations.',
+ 'no_destinations' => 'Chaque nouveau :format publié sur :source attend une destination.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => 'Destinations',
- 'description' => 'Chaque nouvelle vidéo de la source est publiée sur chaque compte sélectionné ici.',
+ 'description' => 'Choisissez les comptes qui le reçoivent. Chacun publie dans le format que vous choisissez.',
'hint' => 'La légende n\'est adaptée par réseau que lorsqu\'elle dépasse la limite de ce réseau.',
'none_available' => 'Aucun autre compte n\'est connecté dans cet espace de travail.',
'save' => 'Enregistrer les destinations',
diff --git a/lang/it/common.php b/lang/it/common.php
index 6415ac1ea..2559c8033 100644
--- a/lang/it/common.php
+++ b/lang/it/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'Beta',
'back' => 'Indietro',
diff --git a/lang/it/repurposes.php b/lang/it/repurposes.php
index b2769b2ab..414da0842 100644
--- a/lang/it/repurposes.php
+++ b/lang/it/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => 'Ogni nuovo :format che pubblichi su :source viene ripubblicato su :destinations.',
+ 'no_destinations' => 'Ogni nuovo :format che pubblichi su :source sta aspettando una destinazione.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => 'Destinazioni',
- 'description' => 'Ogni nuovo video dell\'origine viene pubblicato su tutti gli account selezionati qui.',
+ 'description' => 'Scegli gli account che lo riceveranno. Ognuno pubblica nel formato che imposti.',
'hint' => 'La didascalia viene adattata per rete solo quando supera il limite di quella rete.',
'none_available' => 'Nessun altro account è collegato in questo workspace.',
'save' => 'Salva destinazioni',
diff --git a/lang/ja/common.php b/lang/ja/common.php
index 94cab7827..3584508c0 100644
--- a/lang/ja/common.php
+++ b/lang/ja/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'ベータ',
'back' => '戻る',
diff --git a/lang/ja/repurposes.php b/lang/ja/repurposes.php
index 343f54ae4..29574f985 100644
--- a/lang/ja/repurposes.php
+++ b/lang/ja/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => ':source に新しい :format を投稿するたびに、:destinations へ再投稿されます。',
+ 'no_destinations' => ':source に投稿する新しい :format は、まだ配信先を待っています。',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => '配信先',
- 'description' => 'ソースの新しい動画は、ここで選んだすべてのアカウントに投稿されます。',
+ 'description' => '受け取るアカウントを選びます。それぞれ、指定した形式で投稿します。',
'hint' => 'キャプションは、そのネットワークの上限を超えたときだけ調整されます。',
'none_available' => 'このワークスペースにはまだ他のアカウントが接続されていません。',
'save' => '配信先を保存',
diff --git a/lang/ko/common.php b/lang/ko/common.php
index 3ae65be85..c21f34c40 100644
--- a/lang/ko/common.php
+++ b/lang/ko/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => '베타',
'back' => '뒤로',
diff --git a/lang/ko/repurposes.php b/lang/ko/repurposes.php
index d2798f3e3..214a2c2c8 100644
--- a/lang/ko/repurposes.php
+++ b/lang/ko/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => ':source에 새 :format을 올릴 때마다 :destinations에 다시 게시됩니다.',
+ 'no_destinations' => ':source에 올리는 새 :format이 아직 대상을 기다리고 있습니다.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => '대상',
- 'description' => '소스의 새 영상은 여기서 선택한 모든 계정에 게시됩니다.',
+ 'description' => '받을 계정을 고르세요. 각 계정은 지정한 형식으로 게시합니다.',
'hint' => '캡션은 해당 네트워크의 한도를 넘을 때만 조정됩니다.',
'none_available' => '이 워크스페이스에 연결된 다른 계정이 아직 없습니다.',
'save' => '대상 저장',
diff --git a/lang/nl/common.php b/lang/nl/common.php
index db23bc10d..09986d1da 100644
--- a/lang/nl/common.php
+++ b/lang/nl/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'Bèta',
'back' => 'Terug',
diff --git a/lang/nl/repurposes.php b/lang/nl/repurposes.php
index 515dff1d4..2a658a921 100644
--- a/lang/nl/repurposes.php
+++ b/lang/nl/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => 'Elke nieuwe :format die je op :source plaatst, wordt opnieuw geplaatst op :destinations.',
+ 'no_destinations' => 'Elke nieuwe :format op :source wacht nog op een bestemming.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => 'Bestemmingen',
- 'description' => 'Elke nieuwe video van de bron wordt geplaatst op elk account dat je hier selecteert.',
+ 'description' => 'Kies de accounts die het ontvangen. Elk plaatst in het formaat dat jij kiest.',
'hint' => 'Het bijschrift wordt alleen per netwerk aangepast als het de limiet van dat netwerk overschrijdt.',
'none_available' => 'Er is nog geen ander account gekoppeld in deze workspace.',
'save' => 'Bestemmingen opslaan',
diff --git a/lang/pl/common.php b/lang/pl/common.php
index 227b44bbc..bfbaa2ff7 100644
--- a/lang/pl/common.php
+++ b/lang/pl/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'Beta',
'back' => 'Wstecz',
diff --git a/lang/pl/repurposes.php b/lang/pl/repurposes.php
index 9b8d9b132..caa533c5d 100644
--- a/lang/pl/repurposes.php
+++ b/lang/pl/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => 'Każdy nowy :format opublikowany na :source jest publikowany ponownie na :destinations.',
+ 'no_destinations' => 'Każdy nowy :format opublikowany na :source czeka na cel.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => 'Cele',
- 'description' => 'Każdy nowy film ze źródła trafia na wszystkie zaznaczone tu konta.',
+ 'description' => 'Wybierz konta, które go otrzymają. Każde publikuje w wybranym przez ciebie formacie.',
'hint' => 'Opis jest dostosowywany do sieci tylko wtedy, gdy przekracza jej limit.',
'none_available' => 'W tym obszarze roboczym nie ma jeszcze innego połączonego konta.',
'save' => 'Zapisz cele',
diff --git a/lang/pt-BR/common.php b/lang/pt-BR/common.php
index 9529fd805..2ee7cb102 100644
--- a/lang/pt-BR/common.php
+++ b/lang/pt-BR/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'Beta',
'back' => 'Voltar',
diff --git a/lang/pt-BR/repurposes.php b/lang/pt-BR/repurposes.php
index 7e44dd61d..11c0fceda 100644
--- a/lang/pt-BR/repurposes.php
+++ b/lang/pt-BR/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => 'Cada novo :format que você postar no :source é republicado em :destinations.',
+ 'no_destinations' => 'Cada novo :format que você postar no :source está esperando um destino.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => 'Destinos',
- 'description' => 'Cada novo vídeo da origem é publicado em todas as contas selecionadas aqui.',
+ 'description' => 'Escolha as contas que vão receber. Cada uma publica no formato que você definir.',
'hint' => 'A legenda só é adaptada por rede quando ultrapassa o limite daquela rede.',
'none_available' => 'Nenhuma outra conta está conectada neste workspace.',
'save' => 'Salvar destinos',
diff --git a/lang/ru/common.php b/lang/ru/common.php
index 9534ae1c7..11803954e 100644
--- a/lang/ru/common.php
+++ b/lang/ru/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'Бета',
'back' => 'Назад',
diff --git a/lang/ru/repurposes.php b/lang/ru/repurposes.php
index c94822b2f..d3f11e903 100644
--- a/lang/ru/repurposes.php
+++ b/lang/ru/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => 'Каждое новое :format, опубликованное в :source, повторяется в :destinations.',
+ 'no_destinations' => 'Каждое новое :format в :source ждёт назначения.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => 'Назначения',
- 'description' => 'Каждое новое видео из источника публикуется во всех выбранных здесь аккаунтах.',
+ 'description' => 'Выберите аккаунты-получатели. Каждый публикует в выбранном вами формате.',
'hint' => 'Подпись адаптируется под сеть только тогда, когда превышает её лимит.',
'none_available' => 'В этом рабочем пространстве пока нет других подключённых аккаунтов.',
'save' => 'Сохранить назначения',
diff --git a/lang/tr/common.php b/lang/tr/common.php
index aab1e0291..d5ae5270a 100644
--- a/lang/tr/common.php
+++ b/lang/tr/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'Beta',
'back' => 'Geri',
diff --git a/lang/tr/repurposes.php b/lang/tr/repurposes.php
index 2a74da9a5..109650f6f 100644
--- a/lang/tr/repurposes.php
+++ b/lang/tr/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => ':source üzerinde paylaştığın her yeni :format, :destinations üzerinde yeniden paylaşılır.',
+ 'no_destinations' => ':source üzerinde paylaştığın her yeni :format bir hedef bekliyor.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => 'Hedefler',
- 'description' => 'Kaynaktaki her yeni video, burada seçtiğin tüm hesaplarda yayınlanır.',
+ 'description' => 'Alacak hesapları seç. Her biri senin belirlediğin formatta paylaşır.',
'hint' => 'Açıklama yalnızca o ağın sınırını aştığında ağa göre uyarlanır.',
'none_available' => 'Bu çalışma alanında bağlı başka hesap yok.',
'save' => 'Hedefleri kaydet',
diff --git a/lang/uk/common.php b/lang/uk/common.php
index 6767eb3c3..1af07b4db 100644
--- a/lang/uk/common.php
+++ b/lang/uk/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => 'Бета',
'back' => 'Назад',
diff --git a/lang/uk/repurposes.php b/lang/uk/repurposes.php
index f2f64645f..03933b787 100644
--- a/lang/uk/repurposes.php
+++ b/lang/uk/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => 'Кожне нове :format, опубліковане в :source, повторюється в :destinations.',
+ 'no_destinations' => 'Кожне нове :format у :source чекає на призначення.',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => 'Призначення',
- 'description' => 'Кожне нове відео з джерела публікується в усіх обраних тут акаунтах.',
+ 'description' => 'Оберіть акаунти-отримувачі. Кожен публікує в обраному вами форматі.',
'hint' => 'Підпис адаптується під мережу лише тоді, коли перевищує її ліміт.',
'none_available' => 'У цьому робочому просторі поки немає інших підключених акаунтів.',
'save' => 'Зберегти призначення',
diff --git a/lang/zh/common.php b/lang/zh/common.php
index f570e9148..b7668f668 100644
--- a/lang/zh/common.php
+++ b/lang/zh/common.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
+ 'beta' => '测试版',
'back' => '返回',
diff --git a/lang/zh/repurposes.php b/lang/zh/repurposes.php
index e760c5dc5..2ebc3897c 100644
--- a/lang/zh/repurposes.php
+++ b/lang/zh/repurposes.php
@@ -25,6 +25,7 @@
'summary' => [
'sentence' => '你每次在 :source 发布新的 :format,都会同步到 :destinations。',
+ 'no_destinations' => '你在 :source 发布的每条新 :format 还在等待目标。',
],
'empty' => [
@@ -83,7 +84,7 @@
'destinations' => [
'title' => '目标',
- 'description' => '来源的每条新视频都会发布到你在这里选中的所有账号。',
+ 'description' => '选择接收的账号。每个账号按你指定的格式发布。',
'hint' => '只有当文案超出该平台上限时,才会按平台调整。',
'none_available' => '这个工作区还没有连接其他账号。',
'save' => '保存目标',
diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue
index cd5cebbcf..e9940875e 100644
--- a/resources/js/components/AppSidebar.vue
+++ b/resources/js/components/AppSidebar.vue
@@ -105,6 +105,7 @@ const mainNavItems = computed(() => [
title: trans('sidebar.repurposes'),
href: repurposes.url(),
icon: IconRepeat,
+ badge: trans('common.beta'),
},
]
: []),
diff --git a/resources/js/components/NavMain.vue b/resources/js/components/NavMain.vue
index cb2e37240..ece3b176e 100644
--- a/resources/js/components/NavMain.vue
+++ b/resources/js/components/NavMain.vue
@@ -1,6 +1,7 @@
-
diff --git a/resources/js/types/repurpose.ts b/resources/js/types/repurpose.ts
index d3df3900a..594d1b9f7 100644
--- a/resources/js/types/repurpose.ts
+++ b/resources/js/types/repurpose.ts
@@ -44,7 +44,7 @@ export interface Repurpose {
export interface RepurposeItemPost {
id: string;
- status: string;
+ platform: string | null;
}
export interface RepurposeItem {
From 54a84b62a080fa5c92b34b74dfa1455b326500ec Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sat, 5 Sep 2026 18:02:26 -0300
Subject: [PATCH 015/114] Use the app's own button styles for the repurpose
destructive actions
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.
---
.../js/components/repurpose/RepurposeStatusCard.vue | 2 +-
resources/js/pages/repurposes/Index.vue | 10 ++++++----
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/resources/js/components/repurpose/RepurposeStatusCard.vue b/resources/js/components/repurpose/RepurposeStatusCard.vue
index 7e9c6b05b..e7fa66e64 100644
--- a/resources/js/components/repurpose/RepurposeStatusCard.vue
+++ b/resources/js/components/repurpose/RepurposeStatusCard.vue
@@ -79,7 +79,7 @@ const send = (url: string) => {
diff --git a/resources/js/pages/repurposes/Index.vue b/resources/js/pages/repurposes/Index.vue
index 053bd5e53..9df2bc462 100644
--- a/resources/js/pages/repurposes/Index.vue
+++ b/resources/js/pages/repurposes/Index.vue
@@ -142,12 +142,14 @@ const handleDelete = (repurpose: Repurpose) => {
-
+
From a7727c1168aab523c8f88a51e8cec1d54e2ca307 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sat, 5 Sep 2026 18:08:13 -0300
Subject: [PATCH 016/114] Strip the explanatory comments from the repurpose
module
---
app/Actions/Repurpose/ActivateRepurpose.php | 11 ----------
app/Actions/Repurpose/CreateRepurpose.php | 4 ----
app/Actions/Repurpose/DeleteRepurpose.php | 4 ----
app/Actions/Repurpose/DisableRepurpose.php | 4 ----
app/Actions/Repurpose/PauseRepurpose.php | 4 ----
app/Actions/Repurpose/UpdateRepurpose.php | 4 ----
.../Commands/Repurpose/PollRepurposes.php | 2 --
app/Enums/Repurpose/SourceFormat.php | 21 -------------------
.../Controllers/Api/RepurposeController.php | 3 ---
.../Controllers/App/RepurposeController.php | 20 ------------------
.../App/Repurpose/StoreRepurposeRequest.php | 3 ---
app/Jobs/Repurpose/PollRepurposeSource.php | 18 ----------------
app/Jobs/Repurpose/ProcessRepurposeItem.php | 11 ----------
app/Services/Repurpose/CaptionAdapter.php | 12 -----------
.../Repurpose/FacebookSourceFetcher.php | 11 ----------
.../Repurpose/InstagramSourceFetcher.php | 6 ------
app/Services/Repurpose/SourceFetcher.php | 4 ----
app/Support/PostPlatformMetaRules.php | 17 ++++++---------
app/Support/Repurpose/RepurposeRules.php | 10 ---------
app/Support/Repurpose/Templates.php | 7 -------
..._add_source_format_to_repurposes_table.php | 9 --------
.../js/components/repurpose/PlatformLogo.vue | 4 ----
.../js/components/repurpose/RepurposeFlow.vue | 6 ------
.../components/repurpose/RepurposeSummary.vue | 5 -----
resources/js/pages/repurposes/Show.vue | 13 ------------
tests/Browser/RepurposeTest.php | 4 ----
tests/Feature/Repurpose/ProcessItemTest.php | 2 --
27 files changed, 6 insertions(+), 213 deletions(-)
diff --git a/app/Actions/Repurpose/ActivateRepurpose.php b/app/Actions/Repurpose/ActivateRepurpose.php
index 2ca328200..88f6e0b76 100644
--- a/app/Actions/Repurpose/ActivateRepurpose.php
+++ b/app/Actions/Repurpose/ActivateRepurpose.php
@@ -12,12 +12,6 @@
class ActivateRepurpose
{
- /**
- * Activation stamps the watermark: only media published after this instant
- * is replicated, so turning a repurpose on never floods the destinations
- * with a back catalogue. A repurpose resuming from `paused` keeps the
- * watermark it already had.
- */
public static function execute(Repurpose $repurpose): Repurpose
{
if ($repurpose->destinations === []) {
@@ -38,11 +32,6 @@ public static function execute(Repurpose $repurpose): Repurpose
return $repurpose->fresh();
}
- /**
- * TikTok, Pinterest and Discord each need a piece of meta before anything
- * can reach them. Without this an active repurpose would look healthy and
- * turn every replicated video into a failed post.
- */
private static function assertDestinationsCanPublish(Repurpose $repurpose): void
{
foreach ($repurpose->destinations as $destination) {
diff --git a/app/Actions/Repurpose/CreateRepurpose.php b/app/Actions/Repurpose/CreateRepurpose.php
index 706471af0..1c56cff00 100644
--- a/app/Actions/Repurpose/CreateRepurpose.php
+++ b/app/Actions/Repurpose/CreateRepurpose.php
@@ -14,10 +14,6 @@
class CreateRepurpose
{
/**
- * A repurpose watches one format, so a creator replicating both their Reels
- * and their Stories has two on the same account. Only the same account and
- * the same format together are a duplicate.
- *
* @param array $data
*/
public static function execute(Workspace $workspace, User $user, array $data): Repurpose
diff --git a/app/Actions/Repurpose/DeleteRepurpose.php b/app/Actions/Repurpose/DeleteRepurpose.php
index 7808067a6..847458fcc 100644
--- a/app/Actions/Repurpose/DeleteRepurpose.php
+++ b/app/Actions/Repurpose/DeleteRepurpose.php
@@ -8,10 +8,6 @@
class DeleteRepurpose
{
- /**
- * Items cascade with the repurpose; the posts it generated are the
- * workspace's content and stay, with their back-reference nulled.
- */
public static function execute(Repurpose $repurpose): void
{
$repurpose->delete();
diff --git a/app/Actions/Repurpose/DisableRepurpose.php b/app/Actions/Repurpose/DisableRepurpose.php
index 66a9ccf59..87cca7f19 100644
--- a/app/Actions/Repurpose/DisableRepurpose.php
+++ b/app/Actions/Repurpose/DisableRepurpose.php
@@ -9,10 +9,6 @@
class DisableRepurpose
{
- /**
- * Disabling clears the watermark. Re-activating later stamps a fresh one,
- * so whatever the creator published while it was off stays off.
- */
public static function execute(Repurpose $repurpose): Repurpose
{
$repurpose->update([
diff --git a/app/Actions/Repurpose/PauseRepurpose.php b/app/Actions/Repurpose/PauseRepurpose.php
index f45c58ad8..8e06fe049 100644
--- a/app/Actions/Repurpose/PauseRepurpose.php
+++ b/app/Actions/Repurpose/PauseRepurpose.php
@@ -9,10 +9,6 @@
class PauseRepurpose
{
- /**
- * Pausing keeps the watermark, so resuming picks up where polling stopped
- * and nothing published in the meantime is lost.
- */
public static function execute(Repurpose $repurpose): Repurpose
{
$repurpose->update(['status' => Status::Paused]);
diff --git a/app/Actions/Repurpose/UpdateRepurpose.php b/app/Actions/Repurpose/UpdateRepurpose.php
index f8b36c83b..9b4ce3b1b 100644
--- a/app/Actions/Repurpose/UpdateRepurpose.php
+++ b/app/Actions/Repurpose/UpdateRepurpose.php
@@ -10,10 +10,6 @@
class UpdateRepurpose
{
/**
- * Changing the source account or the watched format resets the watermark:
- * the media the repurpose now looks at is unrelated to what it saw before,
- * and without the reset the whole back catalogue would look new.
- *
* @param array $data
*/
public static function execute(Repurpose $repurpose, array $data): Repurpose
diff --git a/app/Console/Commands/Repurpose/PollRepurposes.php b/app/Console/Commands/Repurpose/PollRepurposes.php
index 4aca44971..8e95bfe42 100644
--- a/app/Console/Commands/Repurpose/PollRepurposes.php
+++ b/app/Console/Commands/Repurpose/PollRepurposes.php
@@ -25,8 +25,6 @@ public function handle(): int
->distinct()
->pluck('source_social_account_id');
- // One job per account, not per repurpose: two repurposes watching the
- // same Instagram for different formats share a single round of calls.
$dispatched = 0;
SocialAccount::query()
diff --git a/app/Enums/Repurpose/SourceFormat.php b/app/Enums/Repurpose/SourceFormat.php
index ba9d4fcbf..41cccf5ce 100644
--- a/app/Enums/Repurpose/SourceFormat.php
+++ b/app/Enums/Repurpose/SourceFormat.php
@@ -7,14 +7,6 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
-/**
- * Which kind of video a repurpose watches for on its source account.
- *
- * A repurpose watches exactly one format. Someone who wants both their Reels
- * and their Stories replicated creates one repurpose per format, which is why
- * a source account is not limited to a single repurpose. Polling groups by
- * account, so two repurposes on one account still cost one round of calls.
- */
enum SourceFormat: string
{
case Reel = 'reel';
@@ -27,8 +19,6 @@ public function label(): string
}
/**
- * Formats a source platform can be watched for.
- *
* @return array
*/
public static function forPlatform(Platform $platform): array
@@ -39,10 +29,6 @@ public static function forPlatform(Platform $platform): array
};
}
- /**
- * Instagram reports `VIDEO` as the media type for a Reel and a feed video
- * alike, so the product type is the only thing telling them apart.
- */
public function instagramProductType(): string
{
return match ($this) {
@@ -52,10 +38,6 @@ public function instagramProductType(): string
};
}
- /**
- * The content type a destination defaults to when this format lands on it,
- * so a picker can open on the closest match rather than on nothing.
- */
public function defaultContentTypeFor(Platform $platform): ?ContentType
{
$candidates = match ($this) {
@@ -73,9 +55,6 @@ public function defaultContentTypeFor(Platform $platform): ?ContentType
}
/**
- * Destination content types that accept a video on a platform. The module
- * only moves video, so anything else is never offered.
- *
* @return array
*/
public static function videoContentTypesFor(Platform $platform): array
diff --git a/app/Http/Controllers/Api/RepurposeController.php b/app/Http/Controllers/Api/RepurposeController.php
index 07e579e5f..774290570 100644
--- a/app/Http/Controllers/Api/RepurposeController.php
+++ b/app/Http/Controllers/Api/RepurposeController.php
@@ -101,9 +101,6 @@ public function destroy(Request $request, Repurpose $repurpose): JsonResponse
return response()->json(null, Response::HTTP_NO_CONTENT);
}
- /**
- * The public API keeps its own fixed page size as a stable contract.
- */
public function items(Request $request, Repurpose $repurpose): AnonymousResourceCollection
{
$this->authorize('view', $repurpose);
diff --git a/app/Http/Controllers/App/RepurposeController.php b/app/Http/Controllers/App/RepurposeController.php
index 710f234e7..3704eca6b 100644
--- a/app/Http/Controllers/App/RepurposeController.php
+++ b/app/Http/Controllers/App/RepurposeController.php
@@ -59,8 +59,6 @@ public function show(Request $request, Repurpose $repurpose): Response
'destinationAccounts' => SocialAccountResource::collection(
$this->connectedAccounts($request)->whereNotIn('id', [$repurpose->source_social_account_id])->values(),
),
- // Through the same resource the API uses, so both surfaces describe
- // an item the same way.
'items' => Inertia::scroll(fn () => RepurposeItemResource::collection(ListRepurposeItems::execute($repurpose))),
'sourceFormats' => $this->sourceFormats($repurpose),
'destinationFormats' => $this->destinationFormats($request, $repurpose->source_format),
@@ -138,9 +136,6 @@ public function destroy(Request $request, Repurpose $repurpose): RedirectRespons
}
/**
- * Formats the source network can be watched for. A repurpose watches one,
- * so replicating Reels and Stories means two repurposes on one account.
- *
* @return array
*/
private function sourceFormats(Repurpose $repurpose): array
@@ -154,10 +149,6 @@ private function sourceFormats(Repurpose $repurpose): array
}
/**
- * Publishable video formats per connected destination account. Anything
- * that cannot carry a video is never offered, and the closest match to what
- * the source watches comes first so a newly picked destination opens on it.
- *
* @return array>
*/
private function destinationFormats(Request $request, SourceFormat $sourceFormat): array
@@ -180,10 +171,6 @@ private function destinationFormats(Request $request, SourceFormat $sourceFormat
}
/**
- * The same per-network settings data the post editor loads, so a repurpose
- * destination configures TikTok privacy, a Pinterest board or a Discord
- * channel through the very components the editor uses.
- *
* @param Collection $accounts
* @return array
*/
@@ -215,9 +202,6 @@ private function platformSettingsProps($accounts): array
];
}
- /**
- * Only networks TryPost can both list and download from can be a source.
- */
private function sourceAccounts(Request $request)
{
return $request->user()->currentWorkspace
@@ -227,10 +211,6 @@ private function sourceAccounts(Request $request)
->get();
}
- /**
- * Accounts, not networks: a workspace may hold two Instagram accounts and
- * both are valid destinations.
- */
private function connectedAccounts(Request $request)
{
return $request->user()->currentWorkspace
diff --git a/app/Http/Requests/App/Repurpose/StoreRepurposeRequest.php b/app/Http/Requests/App/Repurpose/StoreRepurposeRequest.php
index 5aa604e70..5e3a4ae67 100644
--- a/app/Http/Requests/App/Repurpose/StoreRepurposeRequest.php
+++ b/app/Http/Requests/App/Repurpose/StoreRepurposeRequest.php
@@ -16,9 +16,6 @@ public function authorize(): bool
}
/**
- * Creation only asks for the source account; destinations are chosen on the
- * edit screen, where there is room for each network's options.
- *
* @return array
*/
public function rules(): array
diff --git a/app/Jobs/Repurpose/PollRepurposeSource.php b/app/Jobs/Repurpose/PollRepurposeSource.php
index 145b7c510..22ebe832c 100644
--- a/app/Jobs/Repurpose/PollRepurposeSource.php
+++ b/app/Jobs/Repurpose/PollRepurposeSource.php
@@ -26,15 +26,6 @@
use Illuminate\Support\Str;
use Throwable;
-/**
- * Polls one source account for every active repurpose watching it.
- *
- * The job takes an account rather than a repurpose because a creator who wants
- * their Reels and their Stories replicated has two repurposes on the same
- * Instagram. Grouping keeps that at one round of calls, which matters: the
- * Instagram quota is an app-wide pool and this feature's user configures it
- * once and stops opening TryPost, spending quota without feeding it.
- */
class PollRepurposeSource implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@@ -96,9 +87,6 @@ private function watchedFormats(Collection $repurposes): array
}
/**
- * The oldest watermark among the repurposes sharing this account, so one
- * request covers all of them; each then filters what is new to itself.
- *
* @param Collection $repurposes
*/
private function earliestWatermark(Collection $repurposes): mixed
@@ -154,9 +142,6 @@ private function logMedia(Repurpose $repurpose, array $media): void
}
/**
- * Media this workspace published through TryPost, which must never be
- * replicated again.
- *
* @param array $media
* @return array
*/
@@ -172,9 +157,6 @@ private function idsPublishedByTryPost(Repurpose $repurpose, array $media): arra
}
/**
- * A throttled source waits longer than the usual interval, so a workspace
- * that hit Meta's app-wide quota does not keep spending it.
- *
* @param Collection $repurposes
*/
private function recordFailure(Collection $repurposes, Throwable $exception): void
diff --git a/app/Jobs/Repurpose/ProcessRepurposeItem.php b/app/Jobs/Repurpose/ProcessRepurposeItem.php
index 088a9f195..f7f25c9ee 100644
--- a/app/Jobs/Repurpose/ProcessRepurposeItem.php
+++ b/app/Jobs/Repurpose/ProcessRepurposeItem.php
@@ -23,17 +23,6 @@
use Illuminate\Support\Str;
use Throwable;
-/**
- * Turns one video published outside TryPost into posts on the configured
- * destinations.
- *
- * A post carries a single caption that every publisher reads, so one post is
- * created per destination instead of one post with many platforms. That keeps
- * each caption adapted to its own network — a Reel keeps its 2,200 characters
- * even when a YouTube Short in the same repurpose is capped at 100 — without
- * touching the publishing core. The video itself is downloaded once and the
- * same stored file is shared by every post.
- */
class ProcessRepurposeItem implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
diff --git a/app/Services/Repurpose/CaptionAdapter.php b/app/Services/Repurpose/CaptionAdapter.php
index 43debcbfc..a52de85de 100644
--- a/app/Services/Repurpose/CaptionAdapter.php
+++ b/app/Services/Repurpose/CaptionAdapter.php
@@ -14,14 +14,6 @@
use Illuminate\Support\Facades\Log;
use Throwable;
-/**
- * Fits a caption written for one network into another network's hard limit.
- *
- * The caption is left alone whenever it already fits, so the author's own
- * words survive in the common case. AI is only spent on a real overflow, and
- * a workspace without AI access still publishes: it falls back to a clean cut
- * rather than failing the post.
- */
class CaptionAdapter
{
public function __construct(private readonly ContentSanitizer $sanitizer) {}
@@ -104,10 +96,6 @@ private function canUseAi(Workspace $workspace, ?User $user): bool
return Gate::forUser($user)->allows('useAi', $workspace->account);
}
- /**
- * Cuts on the last word boundary that fits, so the caption never ends
- * mid-word.
- */
private function truncate(string $caption, int $limit): string
{
$trimmed = rtrim(mb_substr($caption, 0, $limit));
diff --git a/app/Services/Repurpose/FacebookSourceFetcher.php b/app/Services/Repurpose/FacebookSourceFetcher.php
index b5201605c..273983e11 100644
--- a/app/Services/Repurpose/FacebookSourceFetcher.php
+++ b/app/Services/Repurpose/FacebookSourceFetcher.php
@@ -12,11 +12,6 @@
use Illuminate\Support\Facades\Http;
use RuntimeException;
-/**
- * A Facebook Page splits its video across three edges: `/video_reels` for
- * Reels, `/videos` for everything else, and `/stories` for Stories. Only the
- * edges the caller actually watches are requested.
- */
class FacebookSourceFetcher implements SourceFetcher
{
private const VIDEO_FIELDS = 'id,source,description,permalink_url,created_time';
@@ -39,8 +34,6 @@ public function fetch(SocialAccount $account, ?CarbonInterface $since, array $fo
? $this->stories($account)
: [];
- // `/videos` also lists Reels, so anything already seen as a Reel is
- // dropped from the plain-video list rather than replicated twice.
if ($reels !== [] && $videos !== []) {
$reelIds = array_map(fn (SourceMedia $media): string => $media->id, $reels);
$videos = array_values(array_filter(
@@ -80,10 +73,6 @@ private function videos(SocialAccount $account, string $edge, ?CarbonInterface $
}
/**
- * The stories edge returns the story's media id and its Facebook URL but no
- * downloadable file, so the video behind each published story is resolved
- * in a second request.
- *
* @return array
*/
private function stories(SocialAccount $account): array
diff --git a/app/Services/Repurpose/InstagramSourceFetcher.php b/app/Services/Repurpose/InstagramSourceFetcher.php
index ffecc1c57..2cffcd430 100644
--- a/app/Services/Repurpose/InstagramSourceFetcher.php
+++ b/app/Services/Repurpose/InstagramSourceFetcher.php
@@ -25,12 +25,10 @@ public function fetch(SocialAccount $account, ?CarbonInterface $since, array $fo
{
$media = [];
- // Reels and feed videos share the /media edge, so one call covers both.
if (in_array(SourceFormat::Reel, $formats, true) || in_array(SourceFormat::Video, $formats, true)) {
$media = $this->request($account, 'media', $since);
}
- // Stories are excluded from /media and live on their own edge for 24h.
if (in_array(SourceFormat::Story, $formats, true)) {
$media = [...$media, ...$this->request($account, 'stories', null)];
}
@@ -94,10 +92,6 @@ private function assertSucceeded(Response $response): void
}
}
- /**
- * A direct Instagram login talks to the Instagram graph host; an account
- * connected through a Facebook Page talks to the Facebook one.
- */
private function graphApi(SocialAccount $account): string
{
return $account->platform === Platform::InstagramFacebook
diff --git a/app/Services/Repurpose/SourceFetcher.php b/app/Services/Repurpose/SourceFetcher.php
index 8858b0e8a..421df78c1 100644
--- a/app/Services/Repurpose/SourceFetcher.php
+++ b/app/Services/Repurpose/SourceFetcher.php
@@ -11,10 +11,6 @@
interface SourceFetcher
{
/**
- * Recent media published on the account, including entries the caller will
- * skip. Only the endpoints needed for `$formats` are called, so an account
- * watched for Reels alone never pays for the Stories request.
- *
* @param array $formats
* @return array
*/
diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php
index 70025cdcf..ed795de67 100644
--- a/app/Support/PostPlatformMetaRules.php
+++ b/app/Support/PostPlatformMetaRules.php
@@ -155,17 +155,6 @@ public static function assertStoredPostPublishable(Post $post): void
}
/**
- * The missing required meta field for a platform about to publish, or null when
- * nothing is missing. Single source of "what each platform requires to publish".
- *
- * @return array{0: string, 1: string}|null [field, message]
- */
- /**
- * The meta a platform needs before anything can be published to it, or null
- * when it needs none. Callers that hold a platform and its meta outside a
- * post (a repurpose destination, for one) ask here rather than repeating the
- * list, so a new requirement lands in one place.
- *
* @return array{0: string, 1: string}|null [field, message]
*/
public static function missingRequiredMeta(?Platform $platform, mixed $meta): ?array
@@ -173,6 +162,12 @@ public static function missingRequiredMeta(?Platform $platform, mixed $meta): ?a
return self::requiredMetaViolation($platform, $meta);
}
+ /**
+ * The missing required meta field for a platform about to publish, or null when
+ * nothing is missing. Single source of "what each platform requires to publish".
+ *
+ * @return array{0: string, 1: string}|null [field, message]
+ */
private static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array
{
return match (true) {
diff --git a/app/Support/Repurpose/RepurposeRules.php b/app/Support/Repurpose/RepurposeRules.php
index 1d9bce99f..2057af159 100644
--- a/app/Support/Repurpose/RepurposeRules.php
+++ b/app/Support/Repurpose/RepurposeRules.php
@@ -10,14 +10,6 @@
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
-/**
- * Validation rules for a repurpose, shared by the web, API and MCP surfaces.
- *
- * Destination meta is NOT declared here: it is re-keyed from
- * `PostPlatformMetaRules` so a platform's meta is defined in exactly one place.
- * A key without a rule is stripped by `validated()`, so duplicating the list
- * here would silently drop fields on whichever surface forgot to update.
- */
class RepurposeRules
{
/**
@@ -34,8 +26,6 @@ public static function rules(): array
'required',
'string',
Rule::enum(ContentType::class),
- // The module only moves video, so a destination format that
- // cannot carry one is never valid.
fn (string $attribute, mixed $value, callable $fail) => ContentType::tryFrom((string) $value)?->supportsVideo() === false
? $fail(__('repurposes.errors.destination_needs_video'))
: null,
diff --git a/app/Support/Repurpose/Templates.php b/app/Support/Repurpose/Templates.php
index 1052a8a98..48f4354d1 100644
--- a/app/Support/Repurpose/Templates.php
+++ b/app/Support/Repurpose/Templates.php
@@ -6,13 +6,6 @@
use App\Enums\SocialAccount\Platform;
-/**
- * Ready-made starting points offered in the UI and over the API/MCP.
- *
- * These are presets, not rows: adding a network later is a new entry here plus
- * a fetcher, with no migration. Labels live in the `repurposes` translations,
- * keyed by `key`, so they are never hardcoded in PHP.
- */
class Templates
{
/**
diff --git a/database/migrations/2026_09_05_193803_add_source_format_to_repurposes_table.php b/database/migrations/2026_09_05_193803_add_source_format_to_repurposes_table.php
index b5c943e94..7dfdf3ac3 100644
--- a/database/migrations/2026_09_05_193803_add_source_format_to_repurposes_table.php
+++ b/database/migrations/2026_09_05_193803_add_source_format_to_repurposes_table.php
@@ -15,13 +15,6 @@ public function up(): void
$table->string('source_format')->default(SourceFormat::Reel->value)->after('source_social_account_id');
});
- // A repurpose watches one format, so replicating both Reels and Stories
- // from one account takes two of them. Polling groups by account, so
- // dropping the unique costs no extra calls against Meta's quota.
- //
- // MySQL refuses to drop the only index backing the workspace_id foreign
- // key (SQLSTATE 1553), so the replacement goes in before the unique
- // comes out, in its own statement.
Schema::table('repurposes', function (Blueprint $table) {
$table->index(['workspace_id', 'source_social_account_id'], 'repurposes_workspace_source_index');
});
@@ -33,8 +26,6 @@ public function up(): void
public function down(): void
{
- // Same constraint in reverse: the unique has to exist before the plain
- // index backing the foreign key can go.
Schema::table('repurposes', function (Blueprint $table) {
$table->unique(['workspace_id', 'source_social_account_id']);
});
diff --git a/resources/js/components/repurpose/PlatformLogo.vue b/resources/js/components/repurpose/PlatformLogo.vue
index 0473468fb..d72a6df9b 100644
--- a/resources/js/components/repurpose/PlatformLogo.vue
+++ b/resources/js/components/repurpose/PlatformLogo.vue
@@ -3,10 +3,6 @@ import { computed } from 'vue';
import { getPlatformLabel, getPlatformTheme } from '@/composables/usePlatformLogo';
-/**
- * The same network tile the accounts screen uses: the network's colour, a
- * slight tilt that straightens on hover, and a hard border.
- */
const props = withDefaults(
defineProps<{
platform: string;
diff --git a/resources/js/components/repurpose/RepurposeFlow.vue b/resources/js/components/repurpose/RepurposeFlow.vue
index 59d9dffd5..5a4d74d1f 100644
--- a/resources/js/components/repurpose/RepurposeFlow.vue
+++ b/resources/js/components/repurpose/RepurposeFlow.vue
@@ -6,12 +6,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { getPlatformLabel } from '@/composables/usePlatformLogo';
import type { FlowNode } from '@/types/repurpose';
-/**
- * The one-glance summary of a repurpose: where the video comes from and where
- * it lands. Each logo names its account on hover, because a workspace can hold
- * several accounts on the same network and the logos alone would not say which
- * one is in the flow.
- */
withDefaults(
defineProps<{
source: FlowNode;
diff --git a/resources/js/components/repurpose/RepurposeSummary.vue b/resources/js/components/repurpose/RepurposeSummary.vue
index 11911b344..cedda1eef 100644
--- a/resources/js/components/repurpose/RepurposeSummary.vue
+++ b/resources/js/components/repurpose/RepurposeSummary.vue
@@ -6,11 +6,6 @@ import { getPlatformLabel } from '@/composables/usePlatformLogo';
import type { ChannelAccount } from '@/types/channel';
import type { RepurposeDestination } from '@/types/repurpose';
-/**
- * One plain sentence saying exactly what this repurpose does. It doubles as the
- * page's subtitle: naming the source account is also how you tell one repurpose
- * from another.
- */
const props = defineProps<{
sourceAccount: ChannelAccount | null | undefined;
formatLabel: string;
diff --git a/resources/js/pages/repurposes/Show.vue b/resources/js/pages/repurposes/Show.vue
index 51fe3ab7b..3b990d215 100644
--- a/resources/js/pages/repurposes/Show.vue
+++ b/resources/js/pages/repurposes/Show.vue
@@ -45,13 +45,6 @@ const form = useForm<{ source_format: RepurposeSourceFormat; destinations: Repur
destinations: props.repurpose.destinations ?? [],
});
-/**
- * The destinations reuse the post editor's channel configurator, so every
- * network's own settings — TikTok privacy, a Pinterest board, a Discord
- * channel — come from the components that already know how to ask for them.
- * A repurpose keys its destinations by social account rather than by
- * post_platform, since no post exists yet.
- */
const channels = computed(() =>
props.destinationAccounts.map((account) => {
const destination = form.destinations.find((item) => item.social_account_id === account.id);
@@ -162,10 +155,6 @@ const handleDelete = () => {
-
diff --git a/tests/Browser/RepurposeTest.php b/tests/Browser/RepurposeTest.php
index 31a1b4a09..60d58f767 100644
--- a/tests/Browser/RepurposeTest.php
+++ b/tests/Browser/RepurposeTest.php
@@ -140,3 +140,26 @@ function repurposeOwnerWithAccounts(): array
->assertDontSee(trans('posts.form.warnings.requires_media'))
->assertNoJavaScriptErrors();
});
+
+test('the source account is picked from a searchable list on the edit page', function () {
+ [$user, $workspace, $source] = repurposeOwnerWithAccounts();
+
+ config()->set('trypost.allow_multiple_social_accounts', true);
+
+ $other = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Facebook]);
+
+ $repurpose = Repurpose::factory()->create([
+ 'workspace_id' => $workspace->id,
+ 'source_social_account_id' => $source->id,
+ ]);
+
+ $this->actingAs($user);
+
+ $page = visit(route('app.repurposes.show', $repurpose));
+
+ waitForRepurposeTestId($page, 'source-account-select');
+
+ $page->click('@source-account-select')
+ ->assertSee($other->display_name)
+ ->assertNoJavaScriptErrors();
+});
diff --git a/tests/Feature/Repurpose/WebTest.php b/tests/Feature/Repurpose/WebTest.php
index 2949c0818..491d2f6fb 100644
--- a/tests/Feature/Repurpose/WebTest.php
+++ b/tests/Feature/Repurpose/WebTest.php
@@ -431,3 +431,45 @@ function destinationPayload(SocialAccount $account): array
expect($repurpose->fresh()->publish_mode)->toBe(PublishMode::Draft);
});
+
+test('the source account can be changed from the edit page', function () {
+ config()->set('trypost.allow_multiple_social_accounts', true);
+
+ $other = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::Facebook]);
+
+ $repurpose = Repurpose::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'source_social_account_id' => $this->source->id,
+ 'activated_at' => now()->subDays(3),
+ 'status' => Status::Paused,
+ ]);
+
+ $this->actingAs($this->user)
+ ->get(route('app.repurposes.show', $repurpose))
+ ->assertInertia(fn (AssertableInertia $page) => $page->has('sourceAccounts', 2));
+
+ $this->actingAs($this->user)
+ ->put(route('app.repurposes.update', $repurpose), [
+ 'source_social_account_id' => $other->id,
+ 'destinations' => [destinationPayload($this->tiktok)],
+ ])
+ ->assertSessionHasNoErrors();
+
+ $fresh = $repurpose->fresh();
+
+ expect($fresh->source_social_account_id)->toBe($other->id)
+ ->and($fresh->activated_at->isToday())->toBeTrue();
+});
+
+test('the edit page does not offer an account we cannot download from as a source', function () {
+ $repurpose = Repurpose::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'source_social_account_id' => $this->source->id,
+ ]);
+
+ $this->actingAs($this->user)
+ ->get(route('app.repurposes.show', $repurpose))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->has('sourceAccounts', 1)
+ ->where('sourceAccounts.0.id', $this->source->id));
+});
From a8cb1fe1af83738349f8274fe60689ad1e52a717 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 16:38:40 -0300
Subject: [PATCH 050/114] Free the old source as a destination the moment it
stops being the source
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two places were removing the source from the destinations: the controller took
out the one on the record, the page took out the one in the form. Between
picking a new source and saving, the old one satisfied neither and appeared
nowhere — it was no longer the stored source, and it was not the chosen one.
The page is the only one that knows which account is the source right now, so
the exclusion lives there alone and the server sends every connected account.
The channel tiles and the source options carry a test id keyed by account, so
the browser test can watch one account leave the destinations and the other
arrive without matching on names that appear in both halves of the page.
---
.../Controllers/App/RepurposeController.php | 7 ++--
.../js/components/ChannelConfigurator.vue | 1 +
.../components/repurpose/SourceFormatCard.vue | 7 +++-
tests/Browser/RepurposeTest.php | 33 +++++++++++++++++++
tests/Feature/Repurpose/WebTest.php | 18 +++++++++-
5 files changed, 60 insertions(+), 6 deletions(-)
diff --git a/app/Http/Controllers/App/RepurposeController.php b/app/Http/Controllers/App/RepurposeController.php
index 731783a5b..676ab8096 100644
--- a/app/Http/Controllers/App/RepurposeController.php
+++ b/app/Http/Controllers/App/RepurposeController.php
@@ -56,12 +56,11 @@ public function show(Request $request, Repurpose $repurpose): Response
$this->authorize('view', $repurpose);
$accounts = $this->connectedAccounts($request);
- $destinations = $accounts->whereNotIn('id', [$repurpose->source_social_account_id])->values();
return Inertia::render('repurposes/Show', [
'repurpose' => new RepurposeResource($repurpose->load('sourceAccount')),
'sourceAccounts' => SocialAccountResource::collection($this->sourceAccounts($accounts)),
- 'destinationAccounts' => SocialAccountResource::collection($destinations),
+ 'destinationAccounts' => SocialAccountResource::collection($accounts),
'items' => Inertia::scroll(fn () => RepurposeItemResource::collection(ListRepurposeItems::execute($repurpose))),
'sourceFormats' => $this->sourceFormats($repurpose),
'publishModes' => array_map(
@@ -72,8 +71,8 @@ public function show(Request $request, Repurpose $repurpose): Response
],
PublishMode::cases(),
),
- 'recommendedFormats' => $this->recommendedFormats($destinations, $repurpose->source_format),
- ...$this->platformSettingsProps($destinations),
+ 'recommendedFormats' => $this->recommendedFormats($accounts, $repurpose->source_format),
+ ...$this->platformSettingsProps($accounts),
]);
}
diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue
index 09043eb7b..309caa8d2 100644
--- a/resources/js/components/ChannelConfigurator.vue
+++ b/resources/js/components/ChannelConfigurator.vue
@@ -55,6 +55,7 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel
!channel.issue ? 'opacity-100 hover:opacity-90' : '',
]"
:disabled="Boolean(channel.issue) && !isSelected(channel.id)"
+ :data-testid="`channel-${channel.id}`"
@click="emit('toggle', channel.id)"
>
diff --git a/resources/js/components/repurpose/SourceFormatCard.vue b/resources/js/components/repurpose/SourceFormatCard.vue
index b954dc33f..5ae0c2463 100644
--- a/resources/js/components/repurpose/SourceFormatCard.vue
+++ b/resources/js/components/repurpose/SourceFormatCard.vue
@@ -62,7 +62,12 @@ const accountOptions = computed(() =>
:alt="getPlatformLabel(option.platform)"
class="size-4 shrink-0 rounded-sm"
/>
-
+ {{ option.label }}
diff --git a/tests/Browser/RepurposeTest.php b/tests/Browser/RepurposeTest.php
index 60d58f767..e1304b709 100644
--- a/tests/Browser/RepurposeTest.php
+++ b/tests/Browser/RepurposeTest.php
@@ -163,3 +163,36 @@ function repurposeOwnerWithAccounts(): array
->assertSee($other->display_name)
->assertNoJavaScriptErrors();
});
+
+test('switching the source hands the old one back to the destinations before saving', function () {
+ [$user, $workspace, $source] = repurposeOwnerWithAccounts();
+
+ config()->set('trypost.allow_multiple_social_accounts', true);
+
+ $facebook = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Facebook]);
+
+ $repurpose = Repurpose::factory()->create([
+ 'workspace_id' => $workspace->id,
+ 'source_social_account_id' => $source->id,
+ ]);
+
+ $this->actingAs($user);
+
+ $page = visit(route('app.repurposes.show', $repurpose));
+
+ waitForRepurposeTestId($page, 'source-account-select');
+
+ $page->assertVisible("@channel-{$facebook->id}")
+ ->assertMissing("@channel-{$source->id}")
+ ->click('@source-account-select');
+
+ usleep(300000);
+
+ $page->click("@source-option-{$facebook->id}");
+
+ usleep(400000);
+
+ $page->assertVisible("@channel-{$source->id}")
+ ->assertMissing("@channel-{$facebook->id}")
+ ->assertNoJavaScriptErrors();
+});
diff --git a/tests/Feature/Repurpose/WebTest.php b/tests/Feature/Repurpose/WebTest.php
index 491d2f6fb..9051de59b 100644
--- a/tests/Feature/Repurpose/WebTest.php
+++ b/tests/Feature/Repurpose/WebTest.php
@@ -98,7 +98,7 @@ function destinationPayload(SocialAccount $account): array
->assertInertia(fn (AssertableInertia $page) => $page
->component('repurposes/Show')
->where('repurpose.id', $repurpose->id)
- ->has('destinationAccounts', 1)
+ ->has('destinationAccounts', 2)
->has('items'));
});
@@ -473,3 +473,19 @@ function destinationPayload(SocialAccount $account): array
->has('sourceAccounts', 1)
->where('sourceAccounts.0.id', $this->source->id));
});
+
+test('every connected account is sent so the page can exclude whichever becomes the source', function () {
+ $repurpose = Repurpose::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'source_social_account_id' => $this->source->id,
+ ]);
+
+ $this->actingAs($this->user)
+ ->get(route('app.repurposes.show', $repurpose))
+ ->assertInertia(function (AssertableInertia $page) {
+ $ids = collect($page->toArray()['props']['destinationAccounts'])->pluck('id');
+
+ expect($ids)->toContain($this->source->id)
+ ->and($ids)->toContain($this->tiktok->id);
+ });
+});
From bc97cd14a9825421fd8252e164dc540e268f9759 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 16:52:01 -0300
Subject: [PATCH 051/114] Draw the pipeline the page configures
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The screen that defines the flow was the one place not showing it: the index
draws source, arrow, destinations, and here it was a sentence. The header
draws it now, at the top, above the tabs, so the shape is visible from every
one of them.
The left column was stacking three unrelated things — what we watch, what
happens after, and the lifecycle. The first two are configuration and stay;
the lifecycle is not, and moves to the header where the most consequential
control on the page belongs, next to the status it changes.
That leaves the settings tab holding one delete button, so it folds away and
two tabs remain: the configuration and the activity. Deleting goes behind a
menu ahead of the running controls, which is where something done once in a
repurpose's life belongs.
The save button also claimed to save the destinations while saving the source,
the format and the publishing mode with them.
---
lang/ar/repurposes.php | 8 +-
lang/de/repurposes.php | 8 +-
lang/el/repurposes.php | 8 +-
lang/en/repurposes.php | 8 +-
lang/es/repurposes.php | 8 +-
lang/fr/repurposes.php | 8 +-
lang/it/repurposes.php | 8 +-
lang/ja/repurposes.php | 8 +-
lang/ko/repurposes.php | 8 +-
lang/nl/repurposes.php | 8 +-
lang/pl/repurposes.php | 8 +-
lang/pt-BR/repurposes.php | 8 +-
lang/ru/repurposes.php | 8 +-
lang/tr/repurposes.php | 8 +-
lang/uk/repurposes.php | 8 +-
lang/zh/repurposes.php | 8 +-
...eStatusCard.vue => RepurposeLifecycle.vue} | 56 +++++------
resources/js/pages/repurposes/Show.vue | 99 +++++++++++--------
tests/Browser/RepurposeTest.php | 25 ++++-
19 files changed, 219 insertions(+), 89 deletions(-)
rename resources/js/components/repurpose/{RepurposeStatusCard.vue => RepurposeLifecycle.vue} (56%)
diff --git a/lang/ar/repurposes.php b/lang/ar/repurposes.php
index 97df1918a..e1dfc7b73 100644
--- a/lang/ar/repurposes.php
+++ b/lang/ar/repurposes.php
@@ -111,7 +111,7 @@
'description' => 'اختر الحسابات التي ستستقبله. ينشر كل حساب بالصيغة التي تحددها.',
'hint' => 'يُعدَّل النص لكل شبكة فقط عندما يتجاوز حد تلك الشبكة.',
'none_available' => 'لا يوجد حساب آخر متصل في مساحة العمل هذه بعد.',
- 'save' => 'حفظ الوجهات',
+ 'save' => 'حفظ التغييرات',
'saved' => 'تم حفظ الوجهات',
'publish_as' => 'النشر كـ',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'إجراءات أخرى',
+
+ ],
+
'danger' => [
'title' => 'حذف هذا الـ repurpose',
'description' => 'تتوقف الفحوصات فورًا. تبقى المنشورات التي أُنشئت في تقويمك.',
diff --git a/lang/de/repurposes.php b/lang/de/repurposes.php
index 4e8ddf880..0474ac843 100644
--- a/lang/de/repurposes.php
+++ b/lang/de/repurposes.php
@@ -111,7 +111,7 @@
'description' => 'Wähle die Konten, die es erhalten. Jedes veröffentlicht im Format deiner Wahl.',
'hint' => 'Der Text wird nur dann pro Netzwerk angepasst, wenn er dessen Limit überschreitet.',
'none_available' => 'In diesem Workspace ist noch kein weiteres Konto verbunden.',
- 'save' => 'Ziele speichern',
+ 'save' => 'Änderungen speichern',
'saved' => 'Ziele gespeichert',
'publish_as' => 'Veröffentlichen als',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'Weitere Aktionen',
+
+ ],
+
'danger' => [
'title' => 'Dieses Repurpose löschen',
'description' => 'Die Prüfungen stoppen sofort. Bereits erstellte Beiträge bleiben in deinem Kalender.',
diff --git a/lang/el/repurposes.php b/lang/el/repurposes.php
index d6277b808..bc30446f7 100644
--- a/lang/el/repurposes.php
+++ b/lang/el/repurposes.php
@@ -111,7 +111,7 @@
'description' => 'Διάλεξε τους λογαριασμούς που θα το λάβουν. Καθένας δημοσιεύει στη μορφή που ορίζεις.',
'hint' => 'Η λεζάντα προσαρμόζεται ανά δίκτυο μόνο όταν ξεπερνά το όριο εκείνου του δικτύου.',
'none_available' => 'Δεν υπάρχει άλλος συνδεδεμένος λογαριασμός σε αυτόν τον χώρο εργασίας.',
- 'save' => 'Αποθήκευση προορισμών',
+ 'save' => 'Αποθήκευση αλλαγών',
'saved' => 'Οι προορισμοί αποθηκεύτηκαν',
'publish_as' => 'Δημοσίευση ως',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'Περισσότερες ενέργειες',
+
+ ],
+
'danger' => [
'title' => 'Διαγραφή αυτού του repurpose',
'description' => 'Οι έλεγχοι σταματούν αμέσως. Οι αναρτήσεις που δημιουργήθηκαν παραμένουν στο ημερολόγιό σου.',
diff --git a/lang/en/repurposes.php b/lang/en/repurposes.php
index 19e16c7cb..5fd08ac45 100644
--- a/lang/en/repurposes.php
+++ b/lang/en/repurposes.php
@@ -111,7 +111,7 @@
'description' => 'Pick the accounts that receive it. Each one publishes in the format you choose.',
'hint' => 'Captions are adapted per network only when they exceed that network\'s limit.',
'none_available' => 'No other account is connected in this workspace yet.',
- 'save' => 'Save destinations',
+ 'save' => 'Save changes',
'saved' => 'Destinations saved',
'publish_as' => 'Publish as',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'More actions',
+
+ ],
+
'danger' => [
'title' => 'Delete this repurpose',
'description' => 'Checks stop immediately. Posts already created stay in your calendar.',
diff --git a/lang/es/repurposes.php b/lang/es/repurposes.php
index ce359976e..cb110d3a3 100644
--- a/lang/es/repurposes.php
+++ b/lang/es/repurposes.php
@@ -111,7 +111,7 @@
'description' => 'Elige las cuentas que lo recibirán. Cada una publica en el formato que elijas.',
'hint' => 'El texto solo se adapta por red cuando supera el límite de esa red.',
'none_available' => 'No hay ninguna otra cuenta conectada en este espacio de trabajo.',
- 'save' => 'Guardar destinos',
+ 'save' => 'Guardar cambios',
'saved' => 'Destinos guardados',
'publish_as' => 'Publicar como',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'Más acciones',
+
+ ],
+
'danger' => [
'title' => 'Eliminar este repurpose',
'description' => 'Las comprobaciones se detienen de inmediato. Las publicaciones ya creadas siguen en tu calendario.',
diff --git a/lang/fr/repurposes.php b/lang/fr/repurposes.php
index 02aa8692e..95e8ec1e2 100644
--- a/lang/fr/repurposes.php
+++ b/lang/fr/repurposes.php
@@ -111,7 +111,7 @@
'description' => 'Choisissez les comptes qui le reçoivent. Chacun publie dans le format que vous choisissez.',
'hint' => 'La légende n\'est adaptée par réseau que lorsqu\'elle dépasse la limite de ce réseau.',
'none_available' => 'Aucun autre compte n\'est connecté dans cet espace de travail.',
- 'save' => 'Enregistrer les destinations',
+ 'save' => 'Enregistrer',
'saved' => 'Destinations enregistrées',
'publish_as' => 'Publier comme',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'Plus d\'actions',
+
+ ],
+
'danger' => [
'title' => 'Supprimer ce repurpose',
'description' => 'Les vérifications s\'arrêtent immédiatement. Les publications déjà créées restent dans votre calendrier.',
diff --git a/lang/it/repurposes.php b/lang/it/repurposes.php
index 881099b7f..5cce1f0bd 100644
--- a/lang/it/repurposes.php
+++ b/lang/it/repurposes.php
@@ -111,7 +111,7 @@
'description' => 'Scegli gli account che lo riceveranno. Ognuno pubblica nel formato che imposti.',
'hint' => 'La didascalia viene adattata per rete solo quando supera il limite di quella rete.',
'none_available' => 'Nessun altro account è collegato in questo workspace.',
- 'save' => 'Salva destinazioni',
+ 'save' => 'Salva modifiche',
'saved' => 'Destinazioni salvate',
'publish_as' => 'Pubblica come',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'Altre azioni',
+
+ ],
+
'danger' => [
'title' => 'Elimina questo repurpose',
'description' => 'I controlli si fermano subito. I post già creati restano nel tuo calendario.',
diff --git a/lang/ja/repurposes.php b/lang/ja/repurposes.php
index d94042e67..d919c539b 100644
--- a/lang/ja/repurposes.php
+++ b/lang/ja/repurposes.php
@@ -111,7 +111,7 @@
'description' => '受け取るアカウントを選びます。それぞれ、指定した形式で投稿します。',
'hint' => 'キャプションは、そのネットワークの上限を超えたときだけ調整されます。',
'none_available' => 'このワークスペースにはまだ他のアカウントが接続されていません。',
- 'save' => '配信先を保存',
+ 'save' => '変更を保存',
'saved' => '配信先を保存しました',
'publish_as' => '投稿形式',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'その他の操作',
+
+ ],
+
'danger' => [
'title' => 'この Repurpose を削除',
'description' => 'チェックはすぐに止まります。作成済みの投稿はカレンダーに残ります。',
diff --git a/lang/ko/repurposes.php b/lang/ko/repurposes.php
index 14d6bf71d..72c382129 100644
--- a/lang/ko/repurposes.php
+++ b/lang/ko/repurposes.php
@@ -111,7 +111,7 @@
'description' => '받을 계정을 고르세요. 각 계정은 지정한 형식으로 게시합니다.',
'hint' => '캡션은 해당 네트워크의 한도를 넘을 때만 조정됩니다.',
'none_available' => '이 워크스페이스에 연결된 다른 계정이 아직 없습니다.',
- 'save' => '대상 저장',
+ 'save' => '변경사항 저장',
'saved' => '대상을 저장했습니다',
'publish_as' => '게시 형식',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => '추가 작업',
+
+ ],
+
'danger' => [
'title' => '이 Repurpose 삭제',
'description' => '확인이 즉시 중단됩니다. 이미 만들어진 게시물은 캘린더에 남습니다.',
diff --git a/lang/nl/repurposes.php b/lang/nl/repurposes.php
index e05f2b288..230378367 100644
--- a/lang/nl/repurposes.php
+++ b/lang/nl/repurposes.php
@@ -111,7 +111,7 @@
'description' => 'Kies de accounts die het ontvangen. Elk plaatst in het formaat dat jij kiest.',
'hint' => 'Het bijschrift wordt alleen per netwerk aangepast als het de limiet van dat netwerk overschrijdt.',
'none_available' => 'Er is nog geen ander account gekoppeld in deze workspace.',
- 'save' => 'Bestemmingen opslaan',
+ 'save' => 'Wijzigingen opslaan',
'saved' => 'Bestemmingen opgeslagen',
'publish_as' => 'Plaatsen als',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'Meer acties',
+
+ ],
+
'danger' => [
'title' => 'Deze repurpose verwijderen',
'description' => 'De controles stoppen onmiddellijk. Al gemaakte posts blijven in je kalender staan.',
diff --git a/lang/pl/repurposes.php b/lang/pl/repurposes.php
index 2c376396e..8fdc6e742 100644
--- a/lang/pl/repurposes.php
+++ b/lang/pl/repurposes.php
@@ -111,7 +111,7 @@
'description' => 'Wybierz konta, które go otrzymają. Każde publikuje w wybranym przez ciebie formacie.',
'hint' => 'Opis jest dostosowywany do sieci tylko wtedy, gdy przekracza jej limit.',
'none_available' => 'W tym obszarze roboczym nie ma jeszcze innego połączonego konta.',
- 'save' => 'Zapisz cele',
+ 'save' => 'Zapisz zmiany',
'saved' => 'Cele zapisane',
'publish_as' => 'Publikuj jako',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'Więcej akcji',
+
+ ],
+
'danger' => [
'title' => 'Usuń ten repurpose',
'description' => 'Sprawdzanie zatrzyma się natychmiast. Utworzone już posty pozostaną w kalendarzu.',
diff --git a/lang/pt-BR/repurposes.php b/lang/pt-BR/repurposes.php
index 3e0ddd2bc..b63c3361b 100644
--- a/lang/pt-BR/repurposes.php
+++ b/lang/pt-BR/repurposes.php
@@ -111,7 +111,7 @@
'description' => 'Escolha as contas que vão receber. Cada uma publica no formato que você definir.',
'hint' => 'A legenda só é adaptada por rede quando ultrapassa o limite daquela rede.',
'none_available' => 'Nenhuma outra conta está conectada neste workspace.',
- 'save' => 'Salvar destinos',
+ 'save' => 'Salvar alterações',
'saved' => 'Destinos salvos',
'publish_as' => 'Publicar como',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'Mais ações',
+
+ ],
+
'danger' => [
'title' => 'Excluir este repurpose',
'description' => 'As verificações param na hora. Os posts já criados continuam no seu calendário.',
diff --git a/lang/ru/repurposes.php b/lang/ru/repurposes.php
index 205d04fb8..4173a9a63 100644
--- a/lang/ru/repurposes.php
+++ b/lang/ru/repurposes.php
@@ -111,7 +111,7 @@
'description' => 'Выберите аккаунты-получатели. Каждый публикует в выбранном вами формате.',
'hint' => 'Подпись адаптируется под сеть только тогда, когда превышает её лимит.',
'none_available' => 'В этом рабочем пространстве пока нет других подключённых аккаунтов.',
- 'save' => 'Сохранить назначения',
+ 'save' => 'Сохранить изменения',
'saved' => 'Назначения сохранены',
'publish_as' => 'Публиковать как',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'Другие действия',
+
+ ],
+
'danger' => [
'title' => 'Удалить этот repurpose',
'description' => 'Проверки прекратятся сразу. Уже созданные посты останутся в календаре.',
diff --git a/lang/tr/repurposes.php b/lang/tr/repurposes.php
index ebad71847..6df2b8cea 100644
--- a/lang/tr/repurposes.php
+++ b/lang/tr/repurposes.php
@@ -111,7 +111,7 @@
'description' => 'Alacak hesapları seç. Her biri senin belirlediğin formatta paylaşır.',
'hint' => 'Açıklama yalnızca o ağın sınırını aştığında ağa göre uyarlanır.',
'none_available' => 'Bu çalışma alanında bağlı başka hesap yok.',
- 'save' => 'Hedefleri kaydet',
+ 'save' => 'Değişiklikleri kaydet',
'saved' => 'Hedefler kaydedildi',
'publish_as' => 'Şu olarak paylaş',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'Diğer işlemler',
+
+ ],
+
'danger' => [
'title' => 'Bu repurpose\'u sil',
'description' => 'Kontroller hemen durur. Oluşturulmuş gönderiler takviminde kalır.',
diff --git a/lang/uk/repurposes.php b/lang/uk/repurposes.php
index e9afa2c4a..4b8ecb663 100644
--- a/lang/uk/repurposes.php
+++ b/lang/uk/repurposes.php
@@ -111,7 +111,7 @@
'description' => 'Оберіть акаунти-отримувачі. Кожен публікує в обраному вами форматі.',
'hint' => 'Підпис адаптується під мережу лише тоді, коли перевищує її ліміт.',
'none_available' => 'У цьому робочому просторі поки немає інших підключених акаунтів.',
- 'save' => 'Зберегти призначення',
+ 'save' => 'Зберегти зміни',
'saved' => 'Призначення збережено',
'publish_as' => 'Публікувати як',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => 'Інші дії',
+
+ ],
+
'danger' => [
'title' => 'Видалити цей repurpose',
'description' => 'Перевірки припиняться одразу. Уже створені дописи залишаться в календарі.',
diff --git a/lang/zh/repurposes.php b/lang/zh/repurposes.php
index 5788302b7..466bf43a2 100644
--- a/lang/zh/repurposes.php
+++ b/lang/zh/repurposes.php
@@ -111,7 +111,7 @@
'description' => '选择接收的账号。每个账号按你指定的格式发布。',
'hint' => '只有当文案超出该平台上限时,才会按平台调整。',
'none_available' => '这个工作区还没有连接其他账号。',
- 'save' => '保存目标',
+ 'save' => '保存更改',
'saved' => '目标已保存',
'publish_as' => '发布为',
],
@@ -154,6 +154,12 @@
],
],
+ 'menu' => [
+
+ 'label' => '更多操作',
+
+ ],
+
'danger' => [
'title' => '删除这个 Repurpose',
'description' => '检查会立即停止。已创建的帖子会保留在日历中。',
diff --git a/resources/js/components/repurpose/RepurposeStatusCard.vue b/resources/js/components/repurpose/RepurposeLifecycle.vue
similarity index 56%
rename from resources/js/components/repurpose/RepurposeStatusCard.vue
rename to resources/js/components/repurpose/RepurposeLifecycle.vue
index de6367179..4b5f15236 100644
--- a/resources/js/components/repurpose/RepurposeStatusCard.vue
+++ b/resources/js/components/repurpose/RepurposeLifecycle.vue
@@ -1,13 +1,17 @@
-
-
- {{ $t('repurposes.status_card.title') }}
- {{ $t(`repurposes.status_card.${repurpose.status}_hint`) }}
-
-
-
-
+
+
diff --git a/resources/js/pages/repurposes/Show.vue b/resources/js/pages/repurposes/Show.vue
index b4a916214..8647a2911 100644
--- a/resources/js/pages/repurposes/Show.vue
+++ b/resources/js/pages/repurposes/Show.vue
@@ -1,12 +1,13 @@
@@ -97,14 +116,27 @@ const detail = (item: RepurposeItem): string | null => item.error ?? null;
class="group/post inline-flex items-center gap-1.5 rounded-lg bg-foreground/5 py-1 pr-1.5 pl-2 text-xs font-medium text-foreground transition-colors hover:bg-foreground/10"
>
- {{ post.platforms.map(getPlatformLabel).join(', ') }}
+ {{ post.platforms.map((entry) => getPlatformLabel(entry.platform)).join(', ') }}
+
+
+ {{ $t(`posts.status.${postState(post)}`) }}
+
diff --git a/resources/js/types/repurpose.ts b/resources/js/types/repurpose.ts
index 5881cfc27..f37f0e48a 100644
--- a/resources/js/types/repurpose.ts
+++ b/resources/js/types/repurpose.ts
@@ -46,9 +46,14 @@ export interface Repurpose {
updated_at: string;
}
+export interface RepurposeItemPlatform {
+ platform: string;
+ status: string | null;
+}
+
export interface RepurposeItemPost {
id: string;
- platforms: string[];
+ platforms: RepurposeItemPlatform[];
}
export interface RepurposeItem {
diff --git a/tests/Feature/Repurpose/WebTest.php b/tests/Feature/Repurpose/WebTest.php
index c270df078..0893da265 100644
--- a/tests/Feature/Repurpose/WebTest.php
+++ b/tests/Feature/Repurpose/WebTest.php
@@ -3,11 +3,14 @@
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
+use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Enums\Repurpose\PublishMode;
use App\Enums\Repurpose\SourceFormat;
use App\Enums\Repurpose\Status;
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
+use App\Models\Post;
+use App\Models\PostPlatform;
use App\Models\Repurpose;
use App\Models\RepurposeItem;
use App\Models\SocialAccount;
@@ -540,3 +543,40 @@ function destinationPayload(SocialAccount $account): array
->where('items.data.0.created_at', $item->created_at->toIso8601String())
->where('items.data.0.source_created_at', $item->source_created_at->toIso8601String()));
});
+
+test('the activity list exposes each replicated post status', function () {
+ $repurpose = Repurpose::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'source_social_account_id' => $this->source->id,
+ 'destinations' => [destinationPayload($this->tiktok)],
+ ]);
+
+ $item = RepurposeItem::factory()->for($repurpose)->create();
+
+ $published = Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'repurpose_item_id' => $item->id,
+ ]);
+ PostPlatform::factory()->for($published)->create([
+ 'platform' => Platform::Mastodon,
+ 'enabled' => true,
+ 'status' => PostPlatformStatus::Published,
+ ]);
+
+ $failed = Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'repurpose_item_id' => $item->id,
+ ]);
+ PostPlatform::factory()->for($failed)->create([
+ 'platform' => Platform::Threads,
+ 'enabled' => true,
+ 'status' => PostPlatformStatus::Failed,
+ ]);
+
+ $this->actingAs($this->user)
+ ->get(route('app.repurposes.show', $repurpose))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->has('items.data.0.posts', 2)
+ ->where('items.data.0.posts.0.platforms.0.status', PostPlatformStatus::Published->value)
+ ->where('items.data.0.posts.1.platforms.0.status', PostPlatformStatus::Failed->value));
+});
From 77cfeed5da8656a7c45584ed8d45c97b1797de02 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 19:30:35 -0300
Subject: [PATCH 065/114] Show why a repurpose stopped and what fixes it
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.
---
lang/ar/repurposes.php | 9 +++
lang/de/repurposes.php | 9 +++
lang/el/repurposes.php | 9 +++
lang/en/repurposes.php | 9 +++
lang/es/repurposes.php | 9 +++
lang/fr/repurposes.php | 9 +++
lang/it/repurposes.php | 9 +++
lang/ja/repurposes.php | 9 +++
lang/ko/repurposes.php | 9 +++
lang/nl/repurposes.php | 9 +++
lang/pl/repurposes.php | 9 +++
lang/pt-BR/repurposes.php | 9 +++
lang/ru/repurposes.php | 9 +++
lang/tr/repurposes.php | 9 +++
lang/uk/repurposes.php | 9 +++
lang/zh/repurposes.php | 9 +++
.../repurpose/RepurposeHealthBanner.vue | 62 +++++++++++++++++
.../repurpose/RepurposeLifecycle.vue | 7 +-
.../components/repurpose/RepurposeSummary.vue | 4 ++
.../components/repurpose/SourceFormatCard.vue | 17 ++++-
resources/js/pages/repurposes/Index.vue | 19 ++++--
resources/js/pages/repurposes/Show.vue | 7 +-
resources/js/types/channel.ts | 3 +
resources/js/types/repurpose-status.ts | 13 ++++
resources/js/types/repurpose.ts | 5 +-
tests/Browser/RepurposeAccountHealthTest.php | 66 +++++++++++++++++++
26 files changed, 337 insertions(+), 10 deletions(-)
create mode 100644 resources/js/components/repurpose/RepurposeHealthBanner.vue
create mode 100644 tests/Browser/RepurposeAccountHealthTest.php
diff --git a/lang/ar/repurposes.php b/lang/ar/repurposes.php
index 76474e2c5..a1585ac5e 100644
--- a/lang/ar/repurposes.php
+++ b/lang/ar/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => 'كل :format جديد تنشره على :source يُعاد نشره على :destinations.',
'no_destinations' => 'كل :format جديد تنشره على :source ما زال بانتظار وجهة.',
+ 'no_source' => 'لا يوجد حساب مصدر لهذه الأتمتة. اختر حسابًا لتشغيلها من جديد.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'حذف الـ repurpose',
],
+ 'health' => [
+ 'stopped_itself' => 'توقّفت من تلقاء نفسها — افتحها لمعرفة السبب',
+ 'source_missing' => 'النسخ متوقّف: لا توجد حساب مصدر لهذه الأتمتة. اختر حسابًا ثم استأنفها.',
+ 'source_unusable' => 'النسخ متوقّف: الحساب الذي تراقبه هذه الأتمتة يحتاج إلى إعادة ربط.',
+ 'no_destinations' => 'النسخ متوقّف: لا توجد وجهة متاحة. أضف وجهة ثم استأنفها.',
+ 'ready' => 'تم حل المشكلة. استأنف هذه الأتمتة لتعود إلى النسخ.',
+ ],
+
'errors' => [
'source_already_used' => 'هذا الحساب يغذّي بالفعل repurpose آخر. عدّل ذلك بدلًا منه.',
'source_missing' => 'اختر حسابًا للمراقبة قبل بدء هذه الأتمتة.',
diff --git a/lang/de/repurposes.php b/lang/de/repurposes.php
index dfa5fe107..91250eb5c 100644
--- a/lang/de/repurposes.php
+++ b/lang/de/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => 'Jedes neue :format, das du auf :source postest, wird auf :destinations erneut veröffentlicht.',
'no_destinations' => 'Jedes neue :format auf :source wartet noch auf ein Ziel.',
+ 'no_source' => 'Diese Automatisierung hat kein Quellkonto mehr. Wähle eines aus, um sie neu zu starten.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Repurpose löschen',
],
+ 'health' => [
+ 'stopped_itself' => 'Von selbst gestoppt – öffnen, um zu sehen warum',
+ 'source_missing' => 'Die Replikation pausiert: Diese Automatisierung hat kein Quellkonto. Wähle eines und setze sie fort.',
+ 'source_unusable' => 'Die Replikation pausiert: Das überwachte Konto muss neu verbunden werden.',
+ 'no_destinations' => 'Die Replikation pausiert: Kein Ziel verfügbar. Füge eines hinzu und setze sie fort.',
+ 'ready' => 'Das Problem ist behoben. Setze diese Automatisierung fort, um wieder zu replizieren.',
+ ],
+
'errors' => [
'source_already_used' => 'Dieses Konto speist bereits ein anderes Repurpose. Bearbeite stattdessen jenes.',
'source_missing' => 'Wähle ein Konto zur Überwachung aus, bevor du diese Automatisierung startest.',
diff --git a/lang/el/repurposes.php b/lang/el/repurposes.php
index 77eb59e17..af9f1dce2 100644
--- a/lang/el/repurposes.php
+++ b/lang/el/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => 'Κάθε νέο :format που ανεβάζεις στο :source αναδημοσιεύεται σε :destinations.',
'no_destinations' => 'Κάθε νέο :format στο :source περιμένει ακόμη προορισμό.',
+ 'no_source' => 'Αυτή η αυτοματοποίηση δεν έχει λογαριασμό προέλευσης. Επίλεξε έναν για να την ξεκινήσεις ξανά.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Διαγραφή repurpose',
],
+ 'health' => [
+ 'stopped_itself' => 'Σταμάτησε μόνη της — άνοιξέ την για να δεις γιατί',
+ 'source_missing' => 'Η αναπαραγωγή είναι σε παύση: αυτή η αυτοματοποίηση δεν έχει λογαριασμό προέλευσης. Επίλεξε έναν και συνέχισε.',
+ 'source_unusable' => 'Η αναπαραγωγή είναι σε παύση: ο λογαριασμός που παρακολουθείται χρειάζεται επανασύνδεση.',
+ 'no_destinations' => 'Η αναπαραγωγή είναι σε παύση: δεν υπάρχει διαθέσιμος προορισμός. Πρόσθεσε έναν και συνέχισε.',
+ 'ready' => 'Το πρόβλημα λύθηκε. Συνέχισε αυτήν την αυτοματοποίηση για να ξαναρχίσει η αναπαραγωγή.',
+ ],
+
'errors' => [
'source_already_used' => 'Αυτός ο λογαριασμός τροφοδοτεί ήδη άλλο repurpose. Επεξεργάσου εκείνο.',
'source_missing' => 'Επίλεξε έναν λογαριασμό για παρακολούθηση πριν ξεκινήσεις αυτήν την αυτοματοποίηση.',
diff --git a/lang/en/repurposes.php b/lang/en/repurposes.php
index bdeaae580..f99459046 100644
--- a/lang/en/repurposes.php
+++ b/lang/en/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => 'Every new :format you post on :source is republished to :destinations.',
'no_destinations' => 'Every new :format you post on :source is waiting for a destination.',
+ 'no_source' => 'This repurpose has no source account. Pick one to start it again.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Delete repurpose',
],
+ 'health' => [
+ 'stopped_itself' => 'Stopped on its own — open it to see why',
+ 'source_missing' => 'Replication is on hold: this repurpose has no source account. Pick one, then resume it.',
+ 'source_unusable' => 'Replication is on hold: the account this repurpose watches needs to be reconnected.',
+ 'no_destinations' => 'Replication is on hold: no destination is available. Add one, then resume it.',
+ 'ready' => 'The problem is fixed. Resume this repurpose to start replicating again.',
+ ],
+
'errors' => [
'source_already_used' => 'This account already feeds another repurpose. Edit that one instead.',
'source_missing' => 'Pick an account to watch before starting this repurpose.',
diff --git a/lang/es/repurposes.php b/lang/es/repurposes.php
index 91312078f..b9259ace3 100644
--- a/lang/es/repurposes.php
+++ b/lang/es/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => 'Cada nuevo :format que publiques en :source se republica en :destinations.',
'no_destinations' => 'Cada nuevo :format que publiques en :source está esperando un destino.',
+ 'no_source' => 'Esta automatización no tiene cuenta de origen. Elige una para reactivarla.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Eliminar repurpose',
],
+ 'health' => [
+ 'stopped_itself' => 'Se detuvo sola: ábrela para ver por qué',
+ 'source_missing' => 'La replicación está detenida: esta automatización no tiene cuenta de origen. Elige una y reanúdala.',
+ 'source_unusable' => 'La replicación está detenida: la cuenta que observa esta automatización debe reconectarse.',
+ 'no_destinations' => 'La replicación está detenida: no hay ningún destino disponible. Añade uno y reanúdala.',
+ 'ready' => 'El problema está resuelto. Reanuda esta automatización para volver a replicar.',
+ ],
+
'errors' => [
'source_already_used' => 'Esta cuenta ya alimenta otro repurpose. Edita ese.',
'source_missing' => 'Elige una cuenta para monitorear antes de iniciar esta automatización.',
diff --git a/lang/fr/repurposes.php b/lang/fr/repurposes.php
index 413a4564a..388bfed25 100644
--- a/lang/fr/repurposes.php
+++ b/lang/fr/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => 'Chaque nouveau :format publié sur :source est republié sur :destinations.',
'no_destinations' => 'Chaque nouveau :format publié sur :source attend une destination.',
+ 'no_source' => 'Cette automatisation n\'a plus de compte source. Choisissez-en un pour la relancer.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Supprimer le repurpose',
],
+ 'health' => [
+ 'stopped_itself' => 'Arrêtée d\'elle-même — ouvrez-la pour voir pourquoi',
+ 'source_missing' => 'La réplication est en pause : cette automatisation n\'a pas de compte source. Choisissez-en un, puis reprenez.',
+ 'source_unusable' => 'La réplication est en pause : le compte surveillé doit être reconnecté.',
+ 'no_destinations' => 'La réplication est en pause : aucune destination disponible. Ajoutez-en une, puis reprenez.',
+ 'ready' => 'Le problème est résolu. Reprenez cette automatisation pour recommencer à répliquer.',
+ ],
+
'errors' => [
'source_already_used' => 'Ce compte alimente déjà un autre repurpose. Modifiez celui-là.',
'source_missing' => 'Choisissez un compte à surveiller avant de démarrer cette automatisation.',
diff --git a/lang/it/repurposes.php b/lang/it/repurposes.php
index ef7a361c8..e6a6e3eaa 100644
--- a/lang/it/repurposes.php
+++ b/lang/it/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => 'Ogni nuovo :format che pubblichi su :source viene ripubblicato su :destinations.',
'no_destinations' => 'Ogni nuovo :format che pubblichi su :source sta aspettando una destinazione.',
+ 'no_source' => 'Questa automazione non ha un account di origine. Scegline uno per riavviarla.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Elimina repurpose',
],
+ 'health' => [
+ 'stopped_itself' => 'Si è fermata da sola: aprila per vedere perché',
+ 'source_missing' => 'La replica è in pausa: questa automazione non ha un account di origine. Scegline uno e riprendila.',
+ 'source_unusable' => 'La replica è in pausa: l\'account monitorato deve essere ricollegato.',
+ 'no_destinations' => 'La replica è in pausa: nessuna destinazione disponibile. Aggiungine una e riprendila.',
+ 'ready' => 'Il problema è risolto. Riprendi questa automazione per ricominciare a replicare.',
+ ],
+
'errors' => [
'source_already_used' => 'Questo account alimenta già un altro repurpose. Modifica quello.',
'source_missing' => 'Scegli un account da monitorare prima di avviare questa automazione.',
diff --git a/lang/ja/repurposes.php b/lang/ja/repurposes.php
index df9057ac4..af2d4c265 100644
--- a/lang/ja/repurposes.php
+++ b/lang/ja/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => ':source に新しい :format を投稿するたびに、:destinations へ再投稿されます。',
'no_destinations' => ':source に投稿する新しい :format は、まだ配信先を待っています。',
+ 'no_source' => 'この自動化にはソースアカウントがありません。再開するには選択してください。',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Repurpose を削除',
],
+ 'health' => [
+ 'stopped_itself' => '自動的に停止しました。開いて理由を確認してください',
+ 'source_missing' => '複製は停止中です。この自動化にはソースアカウントがありません。選択してから再開してください。',
+ 'source_unusable' => '複製は停止中です。監視対象のアカウントを再接続してください。',
+ 'no_destinations' => '複製は停止中です。利用できる配信先がありません。追加してから再開してください。',
+ 'ready' => '問題は解消しました。この自動化を再開すると複製が再び始まります。',
+ ],
+
'errors' => [
'source_already_used' => 'このアカウントはすでに別の Repurpose で使われています。そちらを編集してください。',
'source_missing' => 'この自動化を開始する前に、監視するアカウントを選択してください。',
diff --git a/lang/ko/repurposes.php b/lang/ko/repurposes.php
index a526c0aaf..39721fa9a 100644
--- a/lang/ko/repurposes.php
+++ b/lang/ko/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => ':source에 새 :format을 올릴 때마다 :destinations에 다시 게시됩니다.',
'no_destinations' => ':source에 올리는 새 :format이 아직 대상을 기다리고 있습니다.',
+ 'no_source' => '이 자동화에는 소스 계정이 없습니다. 다시 시작하려면 계정을 선택하세요.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Repurpose 삭제',
],
+ 'health' => [
+ 'stopped_itself' => '자동으로 중단되었습니다 — 열어서 이유를 확인하세요',
+ 'source_missing' => '복제가 중단되었습니다. 이 자동화에는 소스 계정이 없습니다. 계정을 선택한 뒤 재개하세요.',
+ 'source_unusable' => '복제가 중단되었습니다. 모니터링 중인 계정을 다시 연결해야 합니다.',
+ 'no_destinations' => '복제가 중단되었습니다. 사용 가능한 대상이 없습니다. 대상을 추가한 뒤 재개하세요.',
+ 'ready' => '문제가 해결되었습니다. 이 자동화를 재개하면 복제가 다시 시작됩니다.',
+ ],
+
'errors' => [
'source_already_used' => '이 계정은 이미 다른 Repurpose에 쓰이고 있습니다. 그것을 수정하세요.',
'source_missing' => '이 자동화를 시작하기 전에 모니터링할 계정을 선택하세요.',
diff --git a/lang/nl/repurposes.php b/lang/nl/repurposes.php
index a428851fd..0bbc7f334 100644
--- a/lang/nl/repurposes.php
+++ b/lang/nl/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => 'Elke nieuwe :format die je op :source plaatst, wordt opnieuw geplaatst op :destinations.',
'no_destinations' => 'Elke nieuwe :format op :source wacht nog op een bestemming.',
+ 'no_source' => 'Deze automatisering heeft geen bronaccount. Kies er een om opnieuw te starten.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Repurpose verwijderen',
],
+ 'health' => [
+ 'stopped_itself' => 'Vanzelf gestopt — open om te zien waarom',
+ 'source_missing' => 'Replicatie staat stil: deze automatisering heeft geen bronaccount. Kies er een en hervat.',
+ 'source_unusable' => 'Replicatie staat stil: het gevolgde account moet opnieuw worden verbonden.',
+ 'no_destinations' => 'Replicatie staat stil: geen bestemming beschikbaar. Voeg er een toe en hervat.',
+ 'ready' => 'Het probleem is opgelost. Hervat deze automatisering om weer te repliceren.',
+ ],
+
'errors' => [
'source_already_used' => 'Dit account voedt al een andere repurpose. Bewerk die in plaats daarvan.',
'source_missing' => 'Kies een account om te volgen voordat je deze automatisering start.',
diff --git a/lang/pl/repurposes.php b/lang/pl/repurposes.php
index df1dd8ee1..dea03fbb2 100644
--- a/lang/pl/repurposes.php
+++ b/lang/pl/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => 'Każdy nowy :format opublikowany na :source jest publikowany ponownie na :destinations.',
'no_destinations' => 'Każdy nowy :format opublikowany na :source czeka na cel.',
+ 'no_source' => 'Ta automatyzacja nie ma konta źródłowego. Wybierz jedno, aby ją wznowić.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Usuń repurpose',
],
+ 'health' => [
+ 'stopped_itself' => 'Zatrzymała się sama — otwórz, aby zobaczyć dlaczego',
+ 'source_missing' => 'Replikacja wstrzymana: ta automatyzacja nie ma konta źródłowego. Wybierz jedno i wznów.',
+ 'source_unusable' => 'Replikacja wstrzymana: monitorowane konto wymaga ponownego połączenia.',
+ 'no_destinations' => 'Replikacja wstrzymana: brak dostępnego miejsca docelowego. Dodaj jedno i wznów.',
+ 'ready' => 'Problem został rozwiązany. Wznów tę automatyzację, aby znowu replikować.',
+ ],
+
'errors' => [
'source_already_used' => 'To konto zasila już inny repurpose. Edytuj tamten.',
'source_missing' => 'Wybierz konto do monitorowania przed uruchomieniem tej automatyzacji.',
diff --git a/lang/pt-BR/repurposes.php b/lang/pt-BR/repurposes.php
index 980e0fc76..2ece4aa1f 100644
--- a/lang/pt-BR/repurposes.php
+++ b/lang/pt-BR/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => 'Cada novo :format que você postar no :source é republicado em :destinations.',
'no_destinations' => 'Cada novo :format que você postar no :source está esperando um destino.',
+ 'no_source' => 'Esta automação está sem conta de origem. Escolha uma para reativá-la.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Excluir repurpose',
],
+ 'health' => [
+ 'stopped_itself' => 'Parou sozinha — abra para ver o motivo',
+ 'source_missing' => 'A replicação está parada: esta automação está sem conta de origem. Escolha uma e retome.',
+ 'source_unusable' => 'A replicação está parada: a conta monitorada por esta automação precisa ser reconectada.',
+ 'no_destinations' => 'A replicação está parada: nenhum destino disponível. Adicione um e retome.',
+ 'ready' => 'O problema foi resolvido. Retome esta automação para voltar a replicar.',
+ ],
+
'errors' => [
'source_already_used' => 'Esta conta já alimenta outro repurpose. Edite aquele.',
'source_missing' => 'Escolha uma conta para monitorar antes de iniciar esta automação.',
diff --git a/lang/ru/repurposes.php b/lang/ru/repurposes.php
index f7dcd56d7..7d388723e 100644
--- a/lang/ru/repurposes.php
+++ b/lang/ru/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => 'Каждое новое :format, опубликованное в :source, повторяется в :destinations.',
'no_destinations' => 'Каждое новое :format в :source ждёт назначения.',
+ 'no_source' => 'У этой автоматизации нет исходного аккаунта. Выберите аккаунт, чтобы запустить её снова.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Удалить repurpose',
],
+ 'health' => [
+ 'stopped_itself' => 'Остановилась сама — откройте, чтобы узнать почему',
+ 'source_missing' => 'Репликация приостановлена: у этой автоматизации нет исходного аккаунта. Выберите его и возобновите.',
+ 'source_unusable' => 'Репликация приостановлена: отслеживаемый аккаунт нужно переподключить.',
+ 'no_destinations' => 'Репликация приостановлена: нет доступных получателей. Добавьте одного и возобновите.',
+ 'ready' => 'Проблема устранена. Возобновите автоматизацию, чтобы снова публиковать.',
+ ],
+
'errors' => [
'source_already_used' => 'Этот аккаунт уже используется в другом repurpose. Отредактируйте его.',
'source_missing' => 'Выберите аккаунт для отслеживания перед запуском этой автоматизации.',
diff --git a/lang/tr/repurposes.php b/lang/tr/repurposes.php
index 541ab834e..cb7110ce2 100644
--- a/lang/tr/repurposes.php
+++ b/lang/tr/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => ':source üzerinde paylaştığın her yeni :format, :destinations üzerinde yeniden paylaşılır.',
'no_destinations' => ':source üzerinde paylaştığın her yeni :format bir hedef bekliyor.',
+ 'no_source' => 'Bu otomasyonun kaynak hesabı yok. Yeniden başlatmak için bir hesap seçin.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Repurpose\'u sil',
],
+ 'health' => [
+ 'stopped_itself' => 'Kendiliğinden durdu — nedenini görmek için açın',
+ 'source_missing' => 'Çoğaltma duraklatıldı: bu otomasyonun kaynak hesabı yok. Bir hesap seçip devam ettirin.',
+ 'source_unusable' => 'Çoğaltma duraklatıldı: izlenen hesabın yeniden bağlanması gerekiyor.',
+ 'no_destinations' => 'Çoğaltma duraklatıldı: kullanılabilir hedef yok. Bir hedef ekleyip devam ettirin.',
+ 'ready' => 'Sorun çözüldü. Yeniden çoğaltmaya başlamak için bu otomasyonu devam ettirin.',
+ ],
+
'errors' => [
'source_already_used' => 'Bu hesap zaten başka bir repurpose\'u besliyor. Onu düzenle.',
'source_missing' => 'Bu otomasyonu başlatmadan önce izlenecek bir hesap seçin.',
diff --git a/lang/uk/repurposes.php b/lang/uk/repurposes.php
index cc900b1bf..6a7390ea2 100644
--- a/lang/uk/repurposes.php
+++ b/lang/uk/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => 'Кожне нове :format, опубліковане в :source, повторюється в :destinations.',
'no_destinations' => 'Кожне нове :format у :source чекає на призначення.',
+ 'no_source' => 'У цієї автоматизації немає вихідного облікового запису. Виберіть його, щоб запустити знову.',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => 'Видалити repurpose',
],
+ 'health' => [
+ 'stopped_itself' => 'Зупинилася сама — відкрийте, щоб дізнатися чому',
+ 'source_missing' => 'Реплікацію призупинено: у цієї автоматизації немає вихідного облікового запису. Виберіть його та відновіть.',
+ 'source_unusable' => 'Реплікацію призупинено: відстежуваний обліковий запис потрібно перепідключити.',
+ 'no_destinations' => 'Реплікацію призупинено: немає доступних призначень. Додайте одне та відновіть.',
+ 'ready' => 'Проблему усунено. Відновіть цю автоматизацію, щоб знову публікувати.',
+ ],
+
'errors' => [
'source_already_used' => 'Цей акаунт уже живить інший repurpose. Відредагуйте його.',
'source_missing' => 'Виберіть обліковий запис для відстеження перед запуском цієї автоматизації.',
diff --git a/lang/zh/repurposes.php b/lang/zh/repurposes.php
index 94433f631..0bdd7087f 100644
--- a/lang/zh/repurposes.php
+++ b/lang/zh/repurposes.php
@@ -47,6 +47,7 @@
'summary' => [
'sentence' => '你每次在 :source 发布新的 :format,都会同步到 :destinations。',
'no_destinations' => '你在 :source 发布的每条新 :format 还在等待目标。',
+ 'no_source' => '此自动化没有来源账号。请选择一个以重新启动。',
],
'empty' => [
@@ -172,6 +173,14 @@
'delete' => '删除 Repurpose',
],
+ 'health' => [
+ 'stopped_itself' => '已自动停止 — 打开查看原因',
+ 'source_missing' => '复制已暂停:此自动化没有来源账号。请选择一个后再继续。',
+ 'source_unusable' => '复制已暂停:此自动化监控的账号需要重新连接。',
+ 'no_destinations' => '复制已暂停:没有可用的目标账号。请添加一个后再继续。',
+ 'ready' => '问题已解决。继续此自动化即可重新开始复制。',
+ ],
+
'errors' => [
'source_already_used' => '这个账号已经用于另一个 Repurpose,请去编辑那一个。',
'source_missing' => '开始此自动化之前,请选择要监控的账号。',
diff --git a/resources/js/components/repurpose/RepurposeHealthBanner.vue b/resources/js/components/repurpose/RepurposeHealthBanner.vue
new file mode 100644
index 000000000..9f6775d9b
--- /dev/null
+++ b/resources/js/components/repurpose/RepurposeHealthBanner.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+ {{ $t(`repurposes.health.${state}`) }}
+
+
+
diff --git a/resources/js/components/repurpose/RepurposeLifecycle.vue b/resources/js/components/repurpose/RepurposeLifecycle.vue
index 4b5f15236..eb6bd22fa 100644
--- a/resources/js/components/repurpose/RepurposeLifecycle.vue
+++ b/resources/js/components/repurpose/RepurposeLifecycle.vue
@@ -34,7 +34,12 @@ const send = (url: string) =>
router.post(url, {}, {
preserveScroll: true,
onError: (errors) =>
- toast.error(errors.status ?? errors.destinations ?? trans('repurposes.errors.action_failed')),
+ toast.error(
+ errors.status
+ ?? errors.source_social_account_id
+ ?? errors.destinations
+ ?? trans('repurposes.errors.action_failed'),
+ ),
});
diff --git a/resources/js/components/repurpose/RepurposeSummary.vue b/resources/js/components/repurpose/RepurposeSummary.vue
index cedda1eef..18a7256cf 100644
--- a/resources/js/components/repurpose/RepurposeSummary.vue
+++ b/resources/js/components/repurpose/RepurposeSummary.vue
@@ -31,6 +31,10 @@ const source = computed(() => {
});
const sentence = computed(() => {
+ if (!props.sourceAccount) {
+ return trans('repurposes.summary.no_source');
+ }
+
if (destinationLabels.value.length === 0) {
return trans('repurposes.summary.no_destinations', {
format: props.formatLabel,
diff --git a/resources/js/components/repurpose/SourceFormatCard.vue b/resources/js/components/repurpose/SourceFormatCard.vue
index 5ae0c2463..554496692 100644
--- a/resources/js/components/repurpose/SourceFormatCard.vue
+++ b/resources/js/components/repurpose/SourceFormatCard.vue
@@ -21,9 +21,22 @@ const props = defineProps<{
error?: string;
}>();
-const account = defineModel('account', { required: true });
+// Nullable: a repurpose whose source account was deleted arrives here with none,
+// and this card is where the user picks a replacement.
+const account = defineModel('account', { required: true });
const format = defineModel('format', { required: true });
+/**
+ * SearchableSelect speaks string | undefined; the repurpose stores null when its
+ * source account was deleted. Bridge the two here rather than widening either.
+ */
+const selectedAccount = computed({
+ get: () => account.value ?? undefined,
+ set: (value: string | undefined) => {
+ account.value = value ?? null;
+ },
+});
+
const accountOptions = computed(() =>
props.accounts.map((item) => ({
value: item.id,
@@ -48,7 +61,7 @@ const accountOptions = computed(() =>
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
-import { IconRepeat, IconTrash } from '@tabler/icons-vue';
+import { IconAlertTriangle, IconRepeat, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
@@ -136,9 +136,20 @@ const handleDelete = (repurpose: Repurpose) => {
{{ repurpose.source_account?.display_name }}
-
- {{ $t(`repurposes.status.${repurpose.status}`) }}
-
+
script(<< {
+ const sel = '[data-testid="{$testId}"]';
+ for (let i = 0; i < 100; i++) {
+ const el = document.querySelector(sel);
+ if (el && el.getBoundingClientRect().height > 0) return;
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ })();
+ JS);
+}
+
+test('a repurpose whose source was deleted explains itself instead of rendering a hole', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+
+ $destination = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::TikTok]);
+
+ // The state the observer leaves behind when the watched account is deleted:
+ // the repurpose and its history survive, with no source to point at.
+ $repurpose = Repurpose::factory()->create([
+ 'workspace_id' => $workspace->id,
+ 'source_social_account_id' => null,
+ 'status' => Status::Paused,
+ 'paused_reason' => PauseReason::SourceRemoved,
+ 'destinations' => [[
+ 'social_account_id' => $destination->id,
+ 'content_type' => 'tiktok_video',
+ 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'],
+ ]],
+ ]);
+
+ $this->actingAs($user);
+
+ $page = visit(route('app.repurposes.show', $repurpose));
+
+ waitForRepurposeHealthTestId($page, 'repurpose-health-banner');
+
+ $page->assertSee(__('repurposes.health.source_missing'))
+ ->assertSee(__('repurposes.summary.no_source'))
+ ->assertNoJavaScriptErrors();
+});
From 768d67f14ce70e1a27616e681c085f6021a08c0f Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 19:32:53 -0300
Subject: [PATCH 066/114] Say which automations a disconnect paused
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../Controllers/Auth/SocialController.php | 52 ++++++++++++++--
lang/ar/accounts.php | 2 +
lang/de/accounts.php | 2 +
lang/el/accounts.php | 2 +
lang/en/accounts.php | 2 +
lang/es/accounts.php | 2 +
lang/fr/accounts.php | 2 +
lang/it/accounts.php | 2 +
lang/ja/accounts.php | 2 +
lang/ko/accounts.php | 2 +
lang/nl/accounts.php | 2 +
lang/pl/accounts.php | 2 +
lang/pt-BR/accounts.php | 2 +
lang/ru/accounts.php | 2 +
lang/tr/accounts.php | 2 +
lang/uk/accounts.php | 2 +
lang/zh/accounts.php | 2 +
tests/Feature/Repurpose/AccountHealthTest.php | 60 +++++++++++++++++++
18 files changed, 139 insertions(+), 5 deletions(-)
diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php
index a082b0c9e..5357659d7 100644
--- a/app/Http/Controllers/Auth/SocialController.php
+++ b/app/Http/Controllers/Auth/SocialController.php
@@ -6,16 +6,19 @@
use App\Actions\SocialAccount\ToggleSocialAccount;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
+use App\Enums\Repurpose\Status as RepurposeStatus;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\ConnectPopupException;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Http\Controllers\Controller;
use App\Http\Resources\App\SocialAccountResource;
+use App\Models\Repurpose;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
+use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
@@ -71,10 +74,11 @@ public function disconnect(Request $request, SocialAccount $account): RedirectRe
->where('status', PostPlatformStatus::Pending->value)
->delete();
+ $affected = $this->repurposeIdsFor($account);
+
$account->delete();
- session()->flash('flash.banner', __('accounts.flash.disconnected'));
- session()->flash('flash.bannerStyle', 'success');
+ $this->flashAccountChange('disconnected', $affected);
return back();
}
@@ -89,11 +93,11 @@ public function toggleActive(Request $request, SocialAccount $account): Redirect
abort(403);
}
+ $affected = $account->is_active ? $this->repurposeIdsFor($account) : collect();
+
ToggleSocialAccount::execute($account);
- $status = $account->is_active ? 'activated' : 'deactivated';
- session()->flash('flash.banner', __("accounts.flash.{$status}"));
- session()->flash('flash.bannerStyle', 'success');
+ $this->flashAccountChange($account->is_active ? 'activated' : 'deactivated', $affected);
return back();
}
@@ -294,4 +298,42 @@ protected function popupCallback(bool $success, string $message, ?string $platfo
'onboardingProgress' => false,
]);
}
+
+ /**
+ * Captured before the account goes away: the source FK is nullOnDelete, so
+ * afterwards there is nothing left linking the two.
+ *
+ * @return Collection
+ */
+ private function repurposeIdsFor(SocialAccount $account): Collection
+ {
+ return Repurpose::query()
+ ->where('source_social_account_id', $account->id)
+ ->where('status', RepurposeStatus::Active)
+ ->pluck('id');
+ }
+
+ /**
+ * The observer has already stopped whatever it was going to stop by now, so
+ * this counts what actually happened rather than predicting it.
+ *
+ * With no email in this flow — the user did this deliberately, so an email
+ * would be noise — the flash is the only notice that an automation stopped,
+ * and it happens on the accounts page rather than where the repurpose lives.
+ *
+ * @param Collection $affected
+ */
+ private function flashAccountChange(string $action, Collection $affected): void
+ {
+ $paused = $affected->isEmpty() ? 0 : Repurpose::query()
+ ->whereKey($affected)
+ ->where('status', RepurposeStatus::Paused)
+ ->whereNotNull('paused_reason')
+ ->count();
+
+ session()->flash('flash.banner', $paused > 0
+ ? trans_choice("accounts.flash.{$action}_paused_repurposes", $paused, ['count' => $paused])
+ : __("accounts.flash.{$action}"));
+ session()->flash('flash.bannerStyle', 'success');
+ }
}
diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php
index 500991a2d..6240375ba 100644
--- a/lang/ar/accounts.php
+++ b/lang/ar/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'تم فصل الحساب. تم إيقاف :count أتمتة مؤقتًا.|تم فصل الحساب. تم إيقاف :count أتمتة مؤقتًا.',
+ 'deactivated_paused_repurposes' => 'تم إيقاف الحساب. تم إيقاف :count أتمتة مؤقتًا.|تم إيقاف الحساب. تم إيقاف :count أتمتة مؤقتًا.',
'disconnected' => 'تم فصل الحساب بنجاح!',
'connected' => 'تم ربط الحساب بنجاح!',
'session_expired' => 'انتهت الجلسة. يرجى المحاولة مرة أخرى.',
diff --git a/lang/de/accounts.php b/lang/de/accounts.php
index f9574718d..dbaa6c563 100644
--- a/lang/de/accounts.php
+++ b/lang/de/accounts.php
@@ -125,6 +125,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'Konto getrennt. :count Automatisierung pausiert.|Konto getrennt. :count Automatisierungen pausiert.',
+ 'deactivated_paused_repurposes' => 'Konto deaktiviert. :count Automatisierung pausiert.|Konto deaktiviert. :count Automatisierungen pausiert.',
'disconnected' => 'Konto erfolgreich getrennt!',
'connected' => 'Konto erfolgreich verbunden!',
'session_expired' => 'Sitzung abgelaufen. Bitte versuche es erneut.',
diff --git a/lang/el/accounts.php b/lang/el/accounts.php
index 2b2fffaf7..ceb6e0ae5 100644
--- a/lang/el/accounts.php
+++ b/lang/el/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'Ο λογαριασμός αποσυνδέθηκε. :count αυτοματοποίηση σε παύση.|Ο λογαριασμός αποσυνδέθηκε. :count αυτοματοποιήσεις σε παύση.',
+ 'deactivated_paused_repurposes' => 'Ο λογαριασμός απενεργοποιήθηκε. :count αυτοματοποίηση σε παύση.|Ο λογαριασμός απενεργοποιήθηκε. :count αυτοματοποιήσεις σε παύση.',
'disconnected' => 'Ο λογαριασμός αποσυνδέθηκε με επιτυχία!',
'connected' => 'Ο λογαριασμός συνδέθηκε με επιτυχία!',
'session_expired' => 'Η συνεδρία έληξε. Παρακαλούμε δοκιμάστε ξανά.',
diff --git a/lang/en/accounts.php b/lang/en/accounts.php
index ce62dbfdb..55f1235c8 100644
--- a/lang/en/accounts.php
+++ b/lang/en/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'Account disconnected. :count automation paused.|Account disconnected. :count automations paused.',
+ 'deactivated_paused_repurposes' => 'Account switched off. :count automation paused.|Account switched off. :count automations paused.',
'disconnected' => 'Account disconnected successfully!',
'connected' => 'Account connected successfully!',
'session_expired' => 'Session expired. Please try again.',
diff --git a/lang/es/accounts.php b/lang/es/accounts.php
index f1f273393..1fa6e3596 100644
--- a/lang/es/accounts.php
+++ b/lang/es/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'Cuenta desconectada. :count automatización en pausa.|Cuenta desconectada. :count automatizaciones en pausa.',
+ 'deactivated_paused_repurposes' => 'Cuenta desactivada. :count automatización en pausa.|Cuenta desactivada. :count automatizaciones en pausa.',
'disconnected' => '¡Cuenta desconectada correctamente!',
'connected' => '¡Cuenta conectada correctamente!',
'session_expired' => 'Sesión expirada. Inténtalo de nuevo.',
diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php
index 7ba02b5fe..4ebedbd3e 100644
--- a/lang/fr/accounts.php
+++ b/lang/fr/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'Compte déconnecté. :count automatisation en pause.|Compte déconnecté. :count automatisations en pause.',
+ 'deactivated_paused_repurposes' => 'Compte désactivé. :count automatisation en pause.|Compte désactivé. :count automatisations en pause.',
'disconnected' => 'Compte déconnecté avec succès !',
'connected' => 'Compte connecté avec succès !',
'session_expired' => 'Session expirée. Veuillez réessayer.',
diff --git a/lang/it/accounts.php b/lang/it/accounts.php
index 20d2deb5f..94d288ace 100644
--- a/lang/it/accounts.php
+++ b/lang/it/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'Account disconnesso. :count automazione in pausa.|Account disconnesso. :count automazioni in pausa.',
+ 'deactivated_paused_repurposes' => 'Account disattivato. :count automazione in pausa.|Account disattivato. :count automazioni in pausa.',
'disconnected' => 'Account scollegato con successo!',
'connected' => 'Account collegato con successo!',
'session_expired' => 'Sessione scaduta. Riprova.',
diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php
index 23f151420..f9f6aba63 100644
--- a/lang/ja/accounts.php
+++ b/lang/ja/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'アカウントを切断しました。:count 件の自動化を停止しました。|アカウントを切断しました。:count 件の自動化を停止しました。',
+ 'deactivated_paused_repurposes' => 'アカウントを無効にしました。:count 件の自動化を停止しました。|アカウントを無効にしました。:count 件の自動化を停止しました。',
'disconnected' => 'アカウントの接続を解除しました!',
'connected' => 'アカウントを接続しました!',
'session_expired' => 'セッションの有効期限が切れました。もう一度お試しください。',
diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php
index 48c2c06cc..a49aaac10 100644
--- a/lang/ko/accounts.php
+++ b/lang/ko/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => '계정 연결을 해제했습니다. 자동화 :count개를 중단했습니다.|계정 연결을 해제했습니다. 자동화 :count개를 중단했습니다.',
+ 'deactivated_paused_repurposes' => '계정을 껐습니다. 자동화 :count개를 중단했습니다.|계정을 껐습니다. 자동화 :count개를 중단했습니다.',
'disconnected' => '계정 연결이 해제되었습니다!',
'connected' => '계정이 연결되었습니다!',
'session_expired' => '세션이 만료되었습니다. 다시 시도해 주세요.',
diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php
index 835ec17d1..87bf687c2 100644
--- a/lang/nl/accounts.php
+++ b/lang/nl/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'Account losgekoppeld. :count automatisering gepauzeerd.|Account losgekoppeld. :count automatiseringen gepauzeerd.',
+ 'deactivated_paused_repurposes' => 'Account uitgeschakeld. :count automatisering gepauzeerd.|Account uitgeschakeld. :count automatiseringen gepauzeerd.',
'disconnected' => 'Account succesvol losgekoppeld!',
'connected' => 'Account succesvol gekoppeld!',
'session_expired' => 'Sessie verlopen. Probeer het opnieuw.',
diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php
index 0d5b98a82..ea08cc9b1 100644
--- a/lang/pl/accounts.php
+++ b/lang/pl/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'Konto odłączone. Wstrzymano :count automatyzację.|Konto odłączone. Wstrzymano :count automatyzacje.',
+ 'deactivated_paused_repurposes' => 'Konto wyłączone. Wstrzymano :count automatyzację.|Konto wyłączone. Wstrzymano :count automatyzacje.',
'disconnected' => 'Konto zostało pomyślnie rozłączone!',
'connected' => 'Konto zostało pomyślnie połączone!',
'session_expired' => 'Sesja wygasła. Spróbuj ponownie.',
diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php
index cb95d308d..9cefa2675 100644
--- a/lang/pt-BR/accounts.php
+++ b/lang/pt-BR/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'Conta desconectada. :count automação pausada.|Conta desconectada. :count automações pausadas.',
+ 'deactivated_paused_repurposes' => 'Conta desativada. :count automação pausada.|Conta desativada. :count automações pausadas.',
'disconnected' => 'Conta desconectada com sucesso!',
'connected' => 'Conta conectada com sucesso!',
'session_expired' => 'Sessão expirada. Por favor, tente novamente.',
diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php
index 32023ef44..4a9d5dab0 100644
--- a/lang/ru/accounts.php
+++ b/lang/ru/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'Аккаунт отключён. Приостановлена :count автоматизация.|Аккаунт отключён. Приостановлено автоматизаций: :count.',
+ 'deactivated_paused_repurposes' => 'Аккаунт выключен. Приостановлена :count автоматизация.|Аккаунт выключен. Приостановлено автоматизаций: :count.',
'disconnected' => 'Аккаунт успешно отключён!',
'connected' => 'Аккаунт успешно подключён!',
'session_expired' => 'Сессия истекла. Попробуйте ещё раз.',
diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php
index dafacd2ff..2551a8fc6 100644
--- a/lang/tr/accounts.php
+++ b/lang/tr/accounts.php
@@ -125,6 +125,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'Hesap bağlantısı kesildi. :count otomasyon duraklatıldı.|Hesap bağlantısı kesildi. :count otomasyon duraklatıldı.',
+ 'deactivated_paused_repurposes' => 'Hesap kapatıldı. :count otomasyon duraklatıldı.|Hesap kapatıldı. :count otomasyon duraklatıldı.',
'disconnected' => 'Hesap bağlantısı başarıyla kesildi!',
'connected' => 'Hesap başarıyla bağlandı!',
'session_expired' => 'Oturum süresi doldu. Lütfen tekrar deneyin.',
diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php
index 20b510076..aece2cc78 100644
--- a/lang/uk/accounts.php
+++ b/lang/uk/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => 'Обліковий запис відключено. Призупинено :count автоматизацію.|Обліковий запис відключено. Призупинено автоматизацій: :count.',
+ 'deactivated_paused_repurposes' => 'Обліковий запис вимкнено. Призупинено :count автоматизацію.|Обліковий запис вимкнено. Призупинено автоматизацій: :count.',
'disconnected' => 'Акаунт успішно від’єднано!',
'connected' => 'Акаунт успішно підключено!',
'session_expired' => 'Сесію завершено. Спробуйте ще раз.',
diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php
index bae4ccdc5..ad12b69b4 100644
--- a/lang/zh/accounts.php
+++ b/lang/zh/accounts.php
@@ -123,6 +123,8 @@
],
'flash' => [
+ 'disconnected_paused_repurposes' => '账号已断开连接。已暂停 :count 个自动化。|账号已断开连接。已暂停 :count 个自动化。',
+ 'deactivated_paused_repurposes' => '账号已关闭。已暂停 :count 个自动化。|账号已关闭。已暂停 :count 个自动化。',
'disconnected' => '账号已成功断开连接!',
'connected' => '账号连接成功!',
'session_expired' => '会话已过期,请重试。',
diff --git a/tests/Feature/Repurpose/AccountHealthTest.php b/tests/Feature/Repurpose/AccountHealthTest.php
index 97137f683..4c970abdb 100644
--- a/tests/Feature/Repurpose/AccountHealthTest.php
+++ b/tests/Feature/Repurpose/AccountHealthTest.php
@@ -446,3 +446,63 @@ function healthDestination(Workspace $workspace): array
expect($repurpose->fresh()->status)->toBe(Status::Paused);
});
+
+test('disconnecting an account says how many automations it paused', function () {
+ // Full HTTP setup, not healthWorkspace(): this route authorises
+ // manageAccounts on the current workspace, so the workspace needs the
+ // user's account_id and the user needs current_workspace_id.
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+
+ $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]);
+
+ Repurpose::factory()->for($workspace)->create([
+ 'source_social_account_id' => $source->id,
+ 'status' => Status::Active,
+ 'destinations' => [healthDestination($workspace)],
+ ]);
+
+ $this->actingAs($user)
+ ->delete(route('app.accounts.disconnect', $source))
+ ->assertSessionHas('flash.banner', trans_choice('accounts.flash.disconnected_paused_repurposes', 1, ['count' => 1]));
+});
+
+test('disconnecting an account with no automations keeps the plain message', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+
+ $account = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]);
+
+ $this->actingAs($user)
+ ->delete(route('app.accounts.disconnect', $account))
+ ->assertSessionHas('flash.banner', __('accounts.flash.disconnected'));
+});
+
+test('switching an account off says how many automations it paused', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+
+ $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]);
+
+ Repurpose::factory()->for($workspace)->create([
+ 'source_social_account_id' => $source->id,
+ 'status' => Status::Active,
+ 'destinations' => [healthDestination($workspace)],
+ ]);
+
+ $this->actingAs($user)
+ ->put(route('app.accounts.toggle', $source))
+ ->assertSessionHas('flash.banner', trans_choice('accounts.flash.deactivated_paused_repurposes', 1, ['count' => 1]));
+});
From d32b03bf50f857962867a2ced744f414c51f0384 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 19:33:54 -0300
Subject: [PATCH 067/114] Record the repurpose account-health decisions
---
AGENTS.md | 41 +++++++++++++++++++++++++++++++++++++++++
CLAUDE.md | 42 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 83 insertions(+)
diff --git a/AGENTS.md b/AGENTS.md
index 9ffbdbdb5..dbbf11a6d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -286,3 +286,44 @@ Standing constraints:
- The editor counts characters and renders the X preview client-side, so the rewrite is mirrored in `resources/js/lib/defuseXLinks.ts`. The TLD list is NOT duplicated there: `PostController@edit` sends `App\Support\LinkTlds::all()` as the `xLinkTlds` page prop, and only when defusing is on — an empty set means the feature is off, since without the list a bare host cannot be told from `Node.js`. Do not move it to the Inertia shared props; only the editor needs it. Two tests keep the mirror honest: `XLinkDefusingParityTest` runs a shared corpus through both engines over the same list and diffs the output, and `tests/Browser/XLinkDefusingTest.php` drives the real editor.
- Neither expression may use lookbehind. Safari only understands it from 16.4, esbuild cannot transpile it, and a `SyntaxError` there takes down the whole chunk — the character before a candidate URL is consumed and put back instead.
+## Repurpose account health
+
+A repurpose depends on social accounts it does not own the lifecycle of. Three
+decisions govern how it reacts, and each exists because the obvious alternative
+was tried and was wrong.
+
+- **A switched-off destination is skipped, never an error.** Deactivating an
+ account means "don't post here", which `ProcessRepurposeItem` already honours.
+ So `ActivateRepurpose::assertDestinationsPublishable()` requires **one** usable
+ destination, not all of them, and the destination rule in the repurpose
+ FormRequests carries **no** `is_active` clause. Requiring either is what used
+ to block editing *and* resuming any repurpose that listed a paused account.
+ Keep the `workspace_id` clause — that is tenancy, not health. The
+ `source_social_account_id` rules stay strict: a source genuinely must work.
+- **`repurposes.paused_reason` is not UI copy.** NULL means the user paused it.
+ Its only two jobs are deciding the watermark on resume (a system pause starts
+ from `now()`, a user pause keeps its place) and deciding whether the system may
+ auto-resume. Banners derive from current account health instead, so they can
+ say "ready to resume" once the cause is fixed. **Never clear it in
+ `UpdateRepurpose`** — that destroys the record that the pause was systemic, and
+ the next Resume replays the entire backlog.
+- **Source and destination are deliberately asymmetric.** A dead source stops the
+ automation; 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.
+
+`RepurposeAccountSync` runs from `SocialAccountObserver` and must never throw:
+`deleting` runs inside `$account->delete()`, and `persistIdentity()` wraps a
+reconnect in a transaction, so an exception there would 500 a disconnect or roll
+back a reconnect. It reads account health **from the database**, not from the
+model it was handed — `is_active` is absent from `SocialAccountFactory`, and
+strict mode exempts recently-created models from the missing-attribute
+exception, so a healthy account read back as `null` and silently skipped
+auto-resume.
+
+No email is sent when a repurpose stops. `markAsTokenExpired()` and
+`VerifyWorkspaceConnections` already email about the account, and reconnecting is
+what auto-resumes the repurpose; deleting or switching an account off is
+something the user just did, so the flash on the accounts page reports the count
+instead.
diff --git a/CLAUDE.md b/CLAUDE.md
index 5d1d19776..ebbb2022e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -434,3 +434,45 @@ Standing constraints:
- NEVER add `Co-Authored-By` lines to commit messages.
- NEVER commit, push, or open PRs unless explicitly asked by the user.
- Always create a new branch for feature work before making changes.
+
+## Repurpose account health
+
+A repurpose depends on social accounts it does not own the lifecycle of. Three
+decisions govern how it reacts, and each exists because the obvious alternative
+was tried and was wrong.
+
+- **A switched-off destination is skipped, never an error.** Deactivating an
+ account means "don't post here", which `ProcessRepurposeItem` already honours.
+ So `ActivateRepurpose::assertDestinationsPublishable()` requires **one** usable
+ destination, not all of them, and the destination rule in the repurpose
+ FormRequests carries **no** `is_active` clause. Requiring either is what used
+ to block editing *and* resuming any repurpose that listed a paused account.
+ Keep the `workspace_id` clause — that is tenancy, not health. The
+ `source_social_account_id` rules stay strict: a source genuinely must work.
+- **`repurposes.paused_reason` is not UI copy.** NULL means the user paused it.
+ Its only two jobs are deciding the watermark on resume (a system pause starts
+ from `now()`, a user pause keeps its place) and deciding whether the system may
+ auto-resume. Banners derive from current account health instead, so they can
+ say "ready to resume" once the cause is fixed. **Never clear it in
+ `UpdateRepurpose`** — that destroys the record that the pause was systemic, and
+ the next Resume replays the entire backlog.
+- **Source and destination are deliberately asymmetric.** A dead source stops the
+ automation; 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.
+
+`RepurposeAccountSync` runs from `SocialAccountObserver` and must never throw:
+`deleting` runs inside `$account->delete()`, and `persistIdentity()` wraps a
+reconnect in a transaction, so an exception there would 500 a disconnect or roll
+back a reconnect. It reads account health **from the database**, not from the
+model it was handed — `is_active` is absent from `SocialAccountFactory`, and
+strict mode exempts recently-created models from the missing-attribute
+exception, so a healthy account read back as `null` and silently skipped
+auto-resume.
+
+No email is sent when a repurpose stops. `markAsTokenExpired()` and
+`VerifyWorkspaceConnections` already email about the account, and reconnecting is
+what auto-resumes the repurpose; deleting or switching an account off is
+something the user just did, so the flash on the accounts page reports the count
+instead.
From 481c8e854201bcbe93314f2e9628baababe1ad0a Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 19:35:30 -0300
Subject: [PATCH 068/114] Use the status enum in the health banner instead of a
literal
---
resources/js/components/repurpose/RepurposeHealthBanner.vue | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/resources/js/components/repurpose/RepurposeHealthBanner.vue b/resources/js/components/repurpose/RepurposeHealthBanner.vue
index 9f6775d9b..01ddfa1bc 100644
--- a/resources/js/components/repurpose/RepurposeHealthBanner.vue
+++ b/resources/js/components/repurpose/RepurposeHealthBanner.vue
@@ -5,6 +5,7 @@ import { computed } from 'vue';
import type { ChannelAccount } from '@/types/channel';
import type { Repurpose } from '@/types/repurpose';
import { RepurposeStatus } from '@/types/repurpose-status';
+import { SocialAccountStatus } from '@/types/social-account-status';
const props = defineProps<{
repurpose: Repurpose;
@@ -28,7 +29,7 @@ const state = computed<'source_missing' | 'source_unusable' | 'no_destinations'
const source = props.accounts.find((account) => account.id === props.repurpose.source_social_account_id);
- if (!source || !source.is_active || source.status !== 'connected') {
+ if (!source || !source.is_active || source.status !== SocialAccountStatus.Connected) {
return 'source_unusable';
}
From 4ab24397ebf406d52d69cdd6e1207d4fb3ab8628 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 19:41:36 -0300
Subject: [PATCH 069/114] Close the gaps the post-implementation review found
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../Controllers/Auth/SocialController.php | 57 +++++++++++--------
app/Jobs/Repurpose/PollRepurposeSource.php | 20 ++++++-
lang/ar/accounts.php | 1 +
lang/ar/repurposes.php | 2 +-
lang/de/accounts.php | 1 +
lang/de/repurposes.php | 2 +-
lang/el/accounts.php | 1 +
lang/el/repurposes.php | 2 +-
lang/en/accounts.php | 1 +
lang/en/repurposes.php | 2 +-
lang/es/accounts.php | 1 +
lang/es/repurposes.php | 2 +-
lang/fr/accounts.php | 1 +
lang/fr/repurposes.php | 2 +-
lang/it/accounts.php | 1 +
lang/it/repurposes.php | 2 +-
lang/ja/accounts.php | 1 +
lang/ja/repurposes.php | 2 +-
lang/ko/accounts.php | 1 +
lang/ko/repurposes.php | 2 +-
lang/nl/accounts.php | 1 +
lang/nl/repurposes.php | 2 +-
lang/pl/accounts.php | 1 +
lang/pl/repurposes.php | 2 +-
lang/pt-BR/accounts.php | 1 +
lang/pt-BR/repurposes.php | 2 +-
lang/ru/accounts.php | 1 +
lang/ru/repurposes.php | 2 +-
lang/tr/accounts.php | 1 +
lang/tr/repurposes.php | 2 +-
lang/uk/accounts.php | 1 +
lang/uk/repurposes.php | 2 +-
lang/zh/accounts.php | 1 +
lang/zh/repurposes.php | 2 +-
tests/Feature/Repurpose/AccountHealthTest.php | 25 ++++++++
tests/Feature/Repurpose/PollingTest.php | 24 ++++++++
36 files changed, 133 insertions(+), 41 deletions(-)
diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php
index 5357659d7..a02193e6f 100644
--- a/app/Http/Controllers/Auth/SocialController.php
+++ b/app/Http/Controllers/Auth/SocialController.php
@@ -74,11 +74,11 @@ public function disconnect(Request $request, SocialAccount $account): RedirectRe
->where('status', PostPlatformStatus::Pending->value)
->delete();
- $affected = $this->repurposeIdsFor($account);
+ $before = $this->repurposeStatesFor($account);
$account->delete();
- $this->flashAccountChange('disconnected', $affected);
+ $this->flashAccountChange('disconnected', $before);
return back();
}
@@ -93,11 +93,13 @@ public function toggleActive(Request $request, SocialAccount $account): Redirect
abort(403);
}
- $affected = $account->is_active ? $this->repurposeIdsFor($account) : collect();
+ // Captured regardless of direction, and never from $account->is_active:
+ // reading a column off the instance is what silently broke isUsable().
+ $before = $this->repurposeStatesFor($account);
ToggleSocialAccount::execute($account);
- $this->flashAccountChange($account->is_active ? 'activated' : 'deactivated', $affected);
+ $this->flashAccountChange($account->is_active ? 'activated' : 'deactivated', $before);
return back();
}
@@ -300,40 +302,49 @@ protected function popupCallback(bool $success, string $message, ?string $platfo
}
/**
- * Captured before the account goes away: the source FK is nullOnDelete, so
- * afterwards there is nothing left linking the two.
+ * Status per repurpose, captured before the account changes. Taken before
+ * the delete because the source FK is nullOnDelete, and used as the
+ * baseline for what the observer went on to change.
*
- * @return Collection
+ * @return Collection
*/
- private function repurposeIdsFor(SocialAccount $account): Collection
+ private function repurposeStatesFor(SocialAccount $account): Collection
{
return Repurpose::query()
->where('source_social_account_id', $account->id)
- ->where('status', RepurposeStatus::Active)
- ->pluck('id');
+ ->pluck('status', 'id');
}
/**
- * The observer has already stopped whatever it was going to stop by now, so
- * this counts what actually happened rather than predicting it.
+ * The observer has already done whatever it was going to do by now, so this
+ * compares before and after rather than predicting either.
*
* With no email in this flow — the user did this deliberately, so an email
- * would be noise — the flash is the only notice that an automation stopped,
- * and it happens on the accounts page rather than where the repurpose lives.
+ * would be noise — the flash is the only notice that an automation stopped
+ * or started, and it happens on the accounts page rather than where the
+ * repurpose lives.
*
- * @param Collection $affected
+ * @param Collection $before
*/
- private function flashAccountChange(string $action, Collection $affected): void
+ private function flashAccountChange(string $action, Collection $before): void
{
- $paused = $affected->isEmpty() ? 0 : Repurpose::query()
- ->whereKey($affected)
- ->where('status', RepurposeStatus::Paused)
- ->whereNotNull('paused_reason')
+ $after = Repurpose::query()->whereKey($before->keys())->pluck('status', 'id');
+
+ $paused = $before
+ ->filter(fn (RepurposeStatus $status, string $id): bool => $status !== RepurposeStatus::Paused
+ && $after->get($id) === RepurposeStatus::Paused)
+ ->count();
+
+ $resumed = $before
+ ->filter(fn (RepurposeStatus $status, string $id): bool => $status === RepurposeStatus::Paused
+ && $after->get($id) === RepurposeStatus::Active)
->count();
- session()->flash('flash.banner', $paused > 0
- ? trans_choice("accounts.flash.{$action}_paused_repurposes", $paused, ['count' => $paused])
- : __("accounts.flash.{$action}"));
+ session()->flash('flash.banner', match (true) {
+ $paused > 0 => trans_choice("accounts.flash.{$action}_paused_repurposes", $paused, ['count' => $paused]),
+ $resumed > 0 => trans_choice("accounts.flash.{$action}_resumed_repurposes", $resumed, ['count' => $resumed]),
+ default => __("accounts.flash.{$action}"),
+ });
session()->flash('flash.bannerStyle', 'success');
}
}
diff --git a/app/Jobs/Repurpose/PollRepurposeSource.php b/app/Jobs/Repurpose/PollRepurposeSource.php
index 9850ab819..fe7b1b8d1 100644
--- a/app/Jobs/Repurpose/PollRepurposeSource.php
+++ b/app/Jobs/Repurpose/PollRepurposeSource.php
@@ -55,8 +55,11 @@ public function handle(SourceFetcherFactory $fetchers): void
}
if ($this->account->disconnected_at !== null || $this->account->is_active === false) {
- // The observer is pausing these; do not overwrite the recorded
- // error or push the schedule out on the way past.
+ // The observer is pausing these. Keep last_error — it is what tells
+ // the user why replication stopped — but still move the schedule, or
+ // the scheduler re-dispatches this on every tick.
+ $this->reschedule($repurposes, $this->interval());
+
return;
}
@@ -195,6 +198,19 @@ private function recordFailure(Collection $repurposes, Throwable $exception): vo
]);
}
+ /**
+ * Moves the schedule and nothing else. markPolled() additionally clears
+ * last_error, which is only right after a poll that actually succeeded.
+ *
+ * @param Collection $repurposes
+ */
+ private function reschedule(Collection $repurposes, int $minutes): void
+ {
+ Repurpose::whereKey($repurposes->modelKeys())->update([
+ 'next_poll_at' => now()->addMinutes($minutes),
+ ]);
+ }
+
/**
* @param Collection $repurposes
*/
diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php
index 6240375ba..06b29447d 100644
--- a/lang/ar/accounts.php
+++ b/lang/ar/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'تم تفعيل الحساب. تم استئناف :count أتمتة.|تم تفعيل الحساب. تم استئناف :count أتمتة.',
'disconnected_paused_repurposes' => 'تم فصل الحساب. تم إيقاف :count أتمتة مؤقتًا.|تم فصل الحساب. تم إيقاف :count أتمتة مؤقتًا.',
'deactivated_paused_repurposes' => 'تم إيقاف الحساب. تم إيقاف :count أتمتة مؤقتًا.|تم إيقاف الحساب. تم إيقاف :count أتمتة مؤقتًا.',
'disconnected' => 'تم فصل الحساب بنجاح!',
diff --git a/lang/ar/repurposes.php b/lang/ar/repurposes.php
index a1585ac5e..e92c98a65 100644
--- a/lang/ar/repurposes.php
+++ b/lang/ar/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'لم توفّر الشبكة ملفًا قابلًا للتنزيل، عادةً بسبب صوت محمي بحقوق النشر',
'download_failed' => 'تعذّر تنزيل الفيديو',
'post_creation_failed' => 'تعذّر إنشاء المنشورات',
- 'no_usable_destinations' => 'تمت إزالة جميع الوجهات أو إيقافها',
+ 'no_usable_destinations' => 'لم تتوفر أي وجهة للنشر',
],
],
diff --git a/lang/de/accounts.php b/lang/de/accounts.php
index dbaa6c563..0fa250d39 100644
--- a/lang/de/accounts.php
+++ b/lang/de/accounts.php
@@ -125,6 +125,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'Konto aktiviert. :count Automatisierung fortgesetzt.|Konto aktiviert. :count Automatisierungen fortgesetzt.',
'disconnected_paused_repurposes' => 'Konto getrennt. :count Automatisierung pausiert.|Konto getrennt. :count Automatisierungen pausiert.',
'deactivated_paused_repurposes' => 'Konto deaktiviert. :count Automatisierung pausiert.|Konto deaktiviert. :count Automatisierungen pausiert.',
'disconnected' => 'Konto erfolgreich getrennt!',
diff --git a/lang/de/repurposes.php b/lang/de/repurposes.php
index 91250eb5c..2e4a78a5d 100644
--- a/lang/de/repurposes.php
+++ b/lang/de/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'Das Netzwerk hat keine herunterladbare Datei bereitgestellt, meist wegen urheberrechtlich geschütztem Audio',
'download_failed' => 'Das Video konnte nicht heruntergeladen werden',
'post_creation_failed' => 'Die Beiträge konnten nicht erstellt werden',
- 'no_usable_destinations' => 'Alle Ziele wurden entfernt oder deaktiviert',
+ 'no_usable_destinations' => 'Kein Ziel war zum Veröffentlichen verfügbar',
],
],
diff --git a/lang/el/accounts.php b/lang/el/accounts.php
index ceb6e0ae5..4d225a482 100644
--- a/lang/el/accounts.php
+++ b/lang/el/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'Ο λογαριασμός ενεργοποιήθηκε. :count αυτοματοποίηση συνεχίστηκε.|Ο λογαριασμός ενεργοποιήθηκε. :count αυτοματοποιήσεις συνεχίστηκαν.',
'disconnected_paused_repurposes' => 'Ο λογαριασμός αποσυνδέθηκε. :count αυτοματοποίηση σε παύση.|Ο λογαριασμός αποσυνδέθηκε. :count αυτοματοποιήσεις σε παύση.',
'deactivated_paused_repurposes' => 'Ο λογαριασμός απενεργοποιήθηκε. :count αυτοματοποίηση σε παύση.|Ο λογαριασμός απενεργοποιήθηκε. :count αυτοματοποιήσεις σε παύση.',
'disconnected' => 'Ο λογαριασμός αποσυνδέθηκε με επιτυχία!',
diff --git a/lang/el/repurposes.php b/lang/el/repurposes.php
index af9f1dce2..1c9165214 100644
--- a/lang/el/repurposes.php
+++ b/lang/el/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'Το δίκτυο δεν έδωσε αρχείο για λήψη, συνήθως λόγω ήχου με πνευματικά δικαιώματα',
'download_failed' => 'Δεν ήταν δυνατή η λήψη του βίντεο',
'post_creation_failed' => 'Δεν ήταν δυνατή η δημιουργία των αναρτήσεων',
- 'no_usable_destinations' => 'Όλοι οι προορισμοί αφαιρέθηκαν ή απενεργοποιήθηκαν',
+ 'no_usable_destinations' => 'Δεν υπήρχε διαθέσιμος προορισμός για δημοσίευση',
],
],
diff --git a/lang/en/accounts.php b/lang/en/accounts.php
index 55f1235c8..e181f2010 100644
--- a/lang/en/accounts.php
+++ b/lang/en/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'Account switched on. :count automation resumed.|Account switched on. :count automations resumed.',
'disconnected_paused_repurposes' => 'Account disconnected. :count automation paused.|Account disconnected. :count automations paused.',
'deactivated_paused_repurposes' => 'Account switched off. :count automation paused.|Account switched off. :count automations paused.',
'disconnected' => 'Account disconnected successfully!',
diff --git a/lang/en/repurposes.php b/lang/en/repurposes.php
index f99459046..7c87c1fc7 100644
--- a/lang/en/repurposes.php
+++ b/lang/en/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'The network did not share a downloadable file, usually because of copyrighted audio',
'download_failed' => 'The video could not be downloaded',
'post_creation_failed' => 'Could not create the posts',
- 'no_usable_destinations' => 'Every destination was removed or switched off',
+ 'no_usable_destinations' => 'No destination was available to publish to',
],
],
diff --git a/lang/es/accounts.php b/lang/es/accounts.php
index 1fa6e3596..a1069f66d 100644
--- a/lang/es/accounts.php
+++ b/lang/es/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'Cuenta activada. :count automatización reanudada.|Cuenta activada. :count automatizaciones reanudadas.',
'disconnected_paused_repurposes' => 'Cuenta desconectada. :count automatización en pausa.|Cuenta desconectada. :count automatizaciones en pausa.',
'deactivated_paused_repurposes' => 'Cuenta desactivada. :count automatización en pausa.|Cuenta desactivada. :count automatizaciones en pausa.',
'disconnected' => '¡Cuenta desconectada correctamente!',
diff --git a/lang/es/repurposes.php b/lang/es/repurposes.php
index b9259ace3..8d7909bdd 100644
--- a/lang/es/repurposes.php
+++ b/lang/es/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'La red no compartió un archivo descargable, normalmente por audio con derechos de autor',
'download_failed' => 'No se pudo descargar el vídeo',
'post_creation_failed' => 'No se pudieron crear las publicaciones',
- 'no_usable_destinations' => 'Todos los destinos fueron eliminados o desactivados',
+ 'no_usable_destinations' => 'No había ningún destino disponible para publicar',
],
],
diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php
index 4ebedbd3e..b1581a078 100644
--- a/lang/fr/accounts.php
+++ b/lang/fr/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'Compte activé. :count automatisation reprise.|Compte activé. :count automatisations reprises.',
'disconnected_paused_repurposes' => 'Compte déconnecté. :count automatisation en pause.|Compte déconnecté. :count automatisations en pause.',
'deactivated_paused_repurposes' => 'Compte désactivé. :count automatisation en pause.|Compte désactivé. :count automatisations en pause.',
'disconnected' => 'Compte déconnecté avec succès !',
diff --git a/lang/fr/repurposes.php b/lang/fr/repurposes.php
index 388bfed25..e23a3a5fd 100644
--- a/lang/fr/repurposes.php
+++ b/lang/fr/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'Le réseau n\'a pas fourni de fichier téléchargeable, généralement à cause d\'un audio protégé par le droit d\'auteur',
'download_failed' => 'La vidéo n\'a pas pu être téléchargée',
'post_creation_failed' => 'Impossible de créer les publications',
- 'no_usable_destinations' => 'Toutes les destinations ont été supprimées ou désactivées',
+ 'no_usable_destinations' => 'Aucune destination n\'était disponible pour publier',
],
],
diff --git a/lang/it/accounts.php b/lang/it/accounts.php
index 94d288ace..525ebc5eb 100644
--- a/lang/it/accounts.php
+++ b/lang/it/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'Account attivato. :count automazione ripresa.|Account attivato. :count automazioni riprese.',
'disconnected_paused_repurposes' => 'Account disconnesso. :count automazione in pausa.|Account disconnesso. :count automazioni in pausa.',
'deactivated_paused_repurposes' => 'Account disattivato. :count automazione in pausa.|Account disattivato. :count automazioni in pausa.',
'disconnected' => 'Account scollegato con successo!',
diff --git a/lang/it/repurposes.php b/lang/it/repurposes.php
index e6a6e3eaa..d5252e107 100644
--- a/lang/it/repurposes.php
+++ b/lang/it/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'La rete non ha fornito un file scaricabile, di solito per audio protetto da copyright',
'download_failed' => 'Non è stato possibile scaricare il video',
'post_creation_failed' => 'Impossibile creare i post',
- 'no_usable_destinations' => 'Tutte le destinazioni sono state rimosse o disattivate',
+ 'no_usable_destinations' => 'Nessuna destinazione era disponibile per pubblicare',
],
],
diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php
index f9f6aba63..b0ff0cf5a 100644
--- a/lang/ja/accounts.php
+++ b/lang/ja/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'アカウントを有効にしました。:count 件の自動化を再開しました。|アカウントを有効にしました。:count 件の自動化を再開しました。',
'disconnected_paused_repurposes' => 'アカウントを切断しました。:count 件の自動化を停止しました。|アカウントを切断しました。:count 件の自動化を停止しました。',
'deactivated_paused_repurposes' => 'アカウントを無効にしました。:count 件の自動化を停止しました。|アカウントを無効にしました。:count 件の自動化を停止しました。',
'disconnected' => 'アカウントの接続を解除しました!',
diff --git a/lang/ja/repurposes.php b/lang/ja/repurposes.php
index af2d4c265..8db702171 100644
--- a/lang/ja/repurposes.php
+++ b/lang/ja/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'ネットワークがダウンロード可能なファイルを返しませんでした。多くは著作権付き音源が原因です',
'download_failed' => '動画をダウンロードできませんでした',
'post_creation_failed' => '投稿を作成できませんでした',
- 'no_usable_destinations' => 'すべての配信先が削除または無効化されました',
+ 'no_usable_destinations' => '公開できる配信先がありませんでした',
],
],
diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php
index a49aaac10..61946bd3e 100644
--- a/lang/ko/accounts.php
+++ b/lang/ko/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => '계정을 켰습니다. 자동화 :count개를 재개했습니다.|계정을 켰습니다. 자동화 :count개를 재개했습니다.',
'disconnected_paused_repurposes' => '계정 연결을 해제했습니다. 자동화 :count개를 중단했습니다.|계정 연결을 해제했습니다. 자동화 :count개를 중단했습니다.',
'deactivated_paused_repurposes' => '계정을 껐습니다. 자동화 :count개를 중단했습니다.|계정을 껐습니다. 자동화 :count개를 중단했습니다.',
'disconnected' => '계정 연결이 해제되었습니다!',
diff --git a/lang/ko/repurposes.php b/lang/ko/repurposes.php
index 39721fa9a..e80025d0e 100644
--- a/lang/ko/repurposes.php
+++ b/lang/ko/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => '네트워크가 내려받을 수 있는 파일을 제공하지 않았습니다. 보통 저작권 오디오 때문입니다',
'download_failed' => '영상을 내려받지 못했습니다',
'post_creation_failed' => '게시물을 만들지 못했습니다',
- 'no_usable_destinations' => '모든 대상이 제거되었거나 비활성화되었습니다',
+ 'no_usable_destinations' => '게시할 수 있는 대상이 없었습니다',
],
],
diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php
index 87bf687c2..d15f68800 100644
--- a/lang/nl/accounts.php
+++ b/lang/nl/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'Account ingeschakeld. :count automatisering hervat.|Account ingeschakeld. :count automatiseringen hervat.',
'disconnected_paused_repurposes' => 'Account losgekoppeld. :count automatisering gepauzeerd.|Account losgekoppeld. :count automatiseringen gepauzeerd.',
'deactivated_paused_repurposes' => 'Account uitgeschakeld. :count automatisering gepauzeerd.|Account uitgeschakeld. :count automatiseringen gepauzeerd.',
'disconnected' => 'Account succesvol losgekoppeld!',
diff --git a/lang/nl/repurposes.php b/lang/nl/repurposes.php
index 0bbc7f334..ea5aacc9b 100644
--- a/lang/nl/repurposes.php
+++ b/lang/nl/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'Het netwerk deelde geen downloadbaar bestand, meestal door auteursrechtelijk beschermde audio',
'download_failed' => 'De video kon niet worden gedownload',
'post_creation_failed' => 'Kon de berichten niet aanmaken',
- 'no_usable_destinations' => 'Alle bestemmingen zijn verwijderd of uitgeschakeld',
+ 'no_usable_destinations' => 'Geen bestemming beschikbaar om naar te publiceren',
],
],
diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php
index ea08cc9b1..e9e1ebdbd 100644
--- a/lang/pl/accounts.php
+++ b/lang/pl/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'Konto włączone. Wznowiono :count automatyzację.|Konto włączone. Wznowiono :count automatyzacje.',
'disconnected_paused_repurposes' => 'Konto odłączone. Wstrzymano :count automatyzację.|Konto odłączone. Wstrzymano :count automatyzacje.',
'deactivated_paused_repurposes' => 'Konto wyłączone. Wstrzymano :count automatyzację.|Konto wyłączone. Wstrzymano :count automatyzacje.',
'disconnected' => 'Konto zostało pomyślnie rozłączone!',
diff --git a/lang/pl/repurposes.php b/lang/pl/repurposes.php
index dea03fbb2..e00dfdf37 100644
--- a/lang/pl/repurposes.php
+++ b/lang/pl/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'Sieć nie udostępniła pliku do pobrania, zwykle z powodu dźwięku chronionego prawem autorskim',
'download_failed' => 'Nie udało się pobrać filmu',
'post_creation_failed' => 'Nie udało się utworzyć postów',
- 'no_usable_destinations' => 'Wszystkie miejsca docelowe usunięto lub wyłączono',
+ 'no_usable_destinations' => 'Brak dostępnego miejsca docelowego do publikacji',
],
],
diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php
index 9cefa2675..7b816cfab 100644
--- a/lang/pt-BR/accounts.php
+++ b/lang/pt-BR/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'Conta ativada. :count automação retomada.|Conta ativada. :count automações retomadas.',
'disconnected_paused_repurposes' => 'Conta desconectada. :count automação pausada.|Conta desconectada. :count automações pausadas.',
'deactivated_paused_repurposes' => 'Conta desativada. :count automação pausada.|Conta desativada. :count automações pausadas.',
'disconnected' => 'Conta desconectada com sucesso!',
diff --git a/lang/pt-BR/repurposes.php b/lang/pt-BR/repurposes.php
index 2ece4aa1f..f34186b59 100644
--- a/lang/pt-BR/repurposes.php
+++ b/lang/pt-BR/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'A rede não disponibilizou o arquivo para download, normalmente por causa de áudio com direitos autorais',
'download_failed' => 'Não foi possível baixar o vídeo',
'post_creation_failed' => 'Não foi possível criar os posts',
- 'no_usable_destinations' => 'Todos os destinos foram removidos ou desativados',
+ 'no_usable_destinations' => 'Nenhum destino estava disponível para publicar',
],
],
diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php
index 4a9d5dab0..577bc5fdd 100644
--- a/lang/ru/accounts.php
+++ b/lang/ru/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'Аккаунт включён. Возобновлена :count автоматизация.|Аккаунт включён. Возобновлено автоматизаций: :count.',
'disconnected_paused_repurposes' => 'Аккаунт отключён. Приостановлена :count автоматизация.|Аккаунт отключён. Приостановлено автоматизаций: :count.',
'deactivated_paused_repurposes' => 'Аккаунт выключен. Приостановлена :count автоматизация.|Аккаунт выключен. Приостановлено автоматизаций: :count.',
'disconnected' => 'Аккаунт успешно отключён!',
diff --git a/lang/ru/repurposes.php b/lang/ru/repurposes.php
index 7d388723e..2f205c32b 100644
--- a/lang/ru/repurposes.php
+++ b/lang/ru/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'Сеть не предоставила файл для скачивания, обычно из-за защищённого авторским правом аудио',
'download_failed' => 'Не удалось скачать видео',
'post_creation_failed' => 'Не удалось создать публикации',
- 'no_usable_destinations' => 'Все получатели удалены или отключены',
+ 'no_usable_destinations' => 'Не было доступных получателей для публикации',
],
],
diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php
index 2551a8fc6..b56a996de 100644
--- a/lang/tr/accounts.php
+++ b/lang/tr/accounts.php
@@ -125,6 +125,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'Hesap açıldı. :count otomasyon devam ediyor.|Hesap açıldı. :count otomasyon devam ediyor.',
'disconnected_paused_repurposes' => 'Hesap bağlantısı kesildi. :count otomasyon duraklatıldı.|Hesap bağlantısı kesildi. :count otomasyon duraklatıldı.',
'deactivated_paused_repurposes' => 'Hesap kapatıldı. :count otomasyon duraklatıldı.|Hesap kapatıldı. :count otomasyon duraklatıldı.',
'disconnected' => 'Hesap bağlantısı başarıyla kesildi!',
diff --git a/lang/tr/repurposes.php b/lang/tr/repurposes.php
index cb7110ce2..cdaafb4a1 100644
--- a/lang/tr/repurposes.php
+++ b/lang/tr/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'Ağ indirilebilir bir dosya paylaşmadı, genellikle telif hakkı korumalı ses nedeniyle',
'download_failed' => 'Video indirilemedi',
'post_creation_failed' => 'Gönderiler oluşturulamadı',
- 'no_usable_destinations' => 'Tüm hedefler kaldırıldı veya kapatıldı',
+ 'no_usable_destinations' => 'Yayınlanacak uygun bir hedef yoktu',
],
],
diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php
index aece2cc78..f4e05888a 100644
--- a/lang/uk/accounts.php
+++ b/lang/uk/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => 'Обліковий запис увімкнено. Відновлено :count автоматизацію.|Обліковий запис увімкнено. Відновлено автоматизацій: :count.',
'disconnected_paused_repurposes' => 'Обліковий запис відключено. Призупинено :count автоматизацію.|Обліковий запис відключено. Призупинено автоматизацій: :count.',
'deactivated_paused_repurposes' => 'Обліковий запис вимкнено. Призупинено :count автоматизацію.|Обліковий запис вимкнено. Призупинено автоматизацій: :count.',
'disconnected' => 'Акаунт успішно від’єднано!',
diff --git a/lang/uk/repurposes.php b/lang/uk/repurposes.php
index 6a7390ea2..57e251abc 100644
--- a/lang/uk/repurposes.php
+++ b/lang/uk/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => 'Мережа не надала файл для завантаження, зазвичай через захищене авторським правом аудіо',
'download_failed' => 'Не вдалося завантажити відео',
'post_creation_failed' => 'Не вдалося створити публікації',
- 'no_usable_destinations' => 'Усі призначення видалено або вимкнено',
+ 'no_usable_destinations' => 'Не було доступних призначень для публікації',
],
],
diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php
index ad12b69b4..bd9a964be 100644
--- a/lang/zh/accounts.php
+++ b/lang/zh/accounts.php
@@ -123,6 +123,7 @@
],
'flash' => [
+ 'activated_resumed_repurposes' => '账号已开启。已恢复 :count 个自动化。|账号已开启。已恢复 :count 个自动化。',
'disconnected_paused_repurposes' => '账号已断开连接。已暂停 :count 个自动化。|账号已断开连接。已暂停 :count 个自动化。',
'deactivated_paused_repurposes' => '账号已关闭。已暂停 :count 个自动化。|账号已关闭。已暂停 :count 个自动化。',
'disconnected' => '账号已成功断开连接!',
diff --git a/lang/zh/repurposes.php b/lang/zh/repurposes.php
index 0bdd7087f..a33ee8d81 100644
--- a/lang/zh/repurposes.php
+++ b/lang/zh/repurposes.php
@@ -157,7 +157,7 @@
'media_url_missing' => '平台没有提供可下载的文件,通常是因为音频有版权',
'download_failed' => '视频下载失败',
'post_creation_failed' => '无法创建帖子',
- 'no_usable_destinations' => '所有目标账号已被移除或关闭',
+ 'no_usable_destinations' => '没有可发布的目标账号',
],
],
diff --git a/tests/Feature/Repurpose/AccountHealthTest.php b/tests/Feature/Repurpose/AccountHealthTest.php
index 4c970abdb..691a2384f 100644
--- a/tests/Feature/Repurpose/AccountHealthTest.php
+++ b/tests/Feature/Repurpose/AccountHealthTest.php
@@ -506,3 +506,28 @@ function healthDestination(Workspace $workspace): array
->put(route('app.accounts.toggle', $source))
->assertSessionHas('flash.banner', trans_choice('accounts.flash.deactivated_paused_repurposes', 1, ['count' => 1]));
});
+
+test('switching an account back on says how many automations resumed', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+
+ $source = SocialAccount::factory()->for($workspace)->create([
+ 'platform' => Platform::Instagram,
+ 'is_active' => false,
+ ]);
+
+ Repurpose::factory()->for($workspace)->create([
+ 'source_social_account_id' => $source->id,
+ 'status' => Status::Paused,
+ 'paused_reason' => PauseReason::SourceUnavailable,
+ 'destinations' => [healthDestination($workspace)],
+ ]);
+
+ $this->actingAs($user)
+ ->put(route('app.accounts.toggle', $source))
+ ->assertSessionHas('flash.banner', trans_choice('accounts.flash.activated_resumed_repurposes', 1, ['count' => 1]));
+});
diff --git a/tests/Feature/Repurpose/PollingTest.php b/tests/Feature/Repurpose/PollingTest.php
index 27b910237..a0d8e7214 100644
--- a/tests/Feature/Repurpose/PollingTest.php
+++ b/tests/Feature/Repurpose/PollingTest.php
@@ -293,3 +293,27 @@ function poll(SocialAccount $account): void
->toContain('[REDACTED]')
->not->toContain('EAAG-super-secret');
});
+
+test('a skipped poll reschedules without erasing the recorded error', function () {
+ $workspace = Workspace::factory()->create();
+ $source = SocialAccount::factory()->for($workspace)->create([
+ 'platform' => Platform::Instagram,
+ 'is_active' => false,
+ ]);
+
+ $repurpose = Repurpose::factory()->active()->create([
+ 'workspace_id' => $workspace->id,
+ 'source_social_account_id' => $source->id,
+ 'last_error' => 'Instagram rejected the request',
+ 'next_poll_at' => now()->subHour(),
+ ]);
+
+ (new PollRepurposeSource($source))->handle(app(SourceFetcherFactory::class));
+
+ $fresh = $repurpose->fresh();
+
+ // The error is what tells the user why it stopped, so it survives. The
+ // schedule still moves, or the scheduler re-dispatches this on every tick.
+ expect($fresh->last_error)->toBe('Instagram rejected the request')
+ ->and($fresh->next_poll_at->isFuture())->toBeTrue();
+});
From 632dbbd079ea15db34c303d684dcdcc05f4fcb6e Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 20:37:08 -0300
Subject: [PATCH 070/114] Report a paused automation when the deleted account
was a destination
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../Controllers/Auth/SocialController.php | 18 +++-
tests/Feature/Repurpose/AccountHealthTest.php | 100 ++++++++++++++++++
2 files changed, 114 insertions(+), 4 deletions(-)
diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php
index a02193e6f..7f49abb0e 100644
--- a/app/Http/Controllers/Auth/SocialController.php
+++ b/app/Http/Controllers/Auth/SocialController.php
@@ -302,16 +302,26 @@ protected function popupCallback(bool $success, string $message, ?string $platfo
}
/**
- * Status per repurpose, captured before the account changes. Taken before
- * the delete because the source FK is nullOnDelete, and used as the
- * baseline for what the observer went on to change.
+ * Status per repurpose that depends on this account, either as its source
+ * or as one of its destinations. Captured before the account changes —
+ * before a delete especially, since the source FK is nullOnDelete — and used
+ * as the baseline for whatever the observer goes on to change.
+ *
+ * Destinations are matched in PHP: they live in a JSON array of objects, and
+ * partial-object containment needs a different candidate shape on PostgreSQL
+ * than on MySQL. The row count is bounded by connected accounts times source
+ * formats.
*
* @return Collection
*/
private function repurposeStatesFor(SocialAccount $account): Collection
{
return Repurpose::query()
- ->where('source_social_account_id', $account->id)
+ ->where('workspace_id', $account->workspace_id)
+ ->get()
+ ->filter(fn (Repurpose $repurpose): bool => $repurpose->source_social_account_id === $account->id
+ || collect($repurpose->destinations)
+ ->contains(fn (array $destination): bool => data_get($destination, 'social_account_id') === $account->id))
->pluck('status', 'id');
}
diff --git a/tests/Feature/Repurpose/AccountHealthTest.php b/tests/Feature/Repurpose/AccountHealthTest.php
index 691a2384f..f159142e8 100644
--- a/tests/Feature/Repurpose/AccountHealthTest.php
+++ b/tests/Feature/Repurpose/AccountHealthTest.php
@@ -6,15 +6,20 @@
use App\Actions\Repurpose\ResumeRepurpose;
use App\Actions\Repurpose\UpdateRepurpose;
use App\Enums\PostPlatform\ContentType;
+use App\Enums\Repurpose\ItemReason;
use App\Enums\Repurpose\PauseReason;
use App\Enums\Repurpose\PublishMode;
use App\Enums\Repurpose\Status;
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status as AccountStatus;
+use App\Jobs\Repurpose\ProcessRepurposeItem;
use App\Models\Repurpose;
+use App\Models\RepurposeItem;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
+use App\Services\Post\MediaAttacher;
+use App\Services\Repurpose\CaptionAdapter;
use App\Support\Repurpose\RepurposeTransition;
use Illuminate\Validation\ValidationException;
@@ -531,3 +536,98 @@ function healthDestination(Workspace $workspace): array
->put(route('app.accounts.toggle', $source))
->assertSessionHas('flash.banner', trans_choice('accounts.flash.activated_resumed_repurposes', 1, ['count' => 1]));
});
+
+test('deleting the last destination account also reports the automation it paused', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+
+ $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]);
+ $only = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Threads]);
+
+ Repurpose::factory()->for($workspace)->create([
+ 'source_social_account_id' => $source->id,
+ 'status' => Status::Active,
+ 'destinations' => [
+ ['social_account_id' => $only->id, 'content_type' => ContentType::ThreadsPost->value, 'meta' => []],
+ ],
+ ]);
+
+ // The account being disconnected is a destination, not the source — the
+ // repurpose still stops, so the flash has to say so.
+ $this->actingAs($user)
+ ->delete(route('app.accounts.disconnect', $only))
+ ->assertSessionHas('flash.banner', trans_choice('accounts.flash.disconnected_paused_repurposes', 1, ['count' => 1]));
+});
+
+test('pruning a destination from a draft repurpose does not pause it', function () {
+ [$workspace, $user, $source] = healthWorkspace();
+ $only = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Threads]);
+
+ $repurpose = Repurpose::factory()->for($workspace)->create([
+ 'source_social_account_id' => $source->id,
+ 'status' => Status::Draft,
+ 'destinations' => [
+ ['social_account_id' => $only->id, 'content_type' => ContentType::ThreadsPost->value, 'meta' => []],
+ ],
+ ]);
+
+ $only->delete();
+
+ expect($repurpose->fresh()->destinations)->toBe([])
+ ->and($repurpose->fresh()->status)->toBe(Status::Draft)
+ ->and($repurpose->fresh()->paused_reason)->toBeNull();
+});
+
+test('a supported content type survives a platform change untouched', function () {
+ // healthWorkspace() already seats an Instagram as the source, and the
+ // one-account-per-network rule would refuse a second one.
+ config()->set('trypost.allow_multiple_social_accounts', true);
+
+ [$workspace, $user, $source] = healthWorkspace();
+
+ $instagram = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]);
+
+ $repurpose = Repurpose::factory()->for($workspace)->create([
+ 'source_social_account_id' => $source->id,
+ 'status' => Status::Active,
+ 'destinations' => [
+ ['social_account_id' => $instagram->id, 'content_type' => ContentType::InstagramReel->value, 'meta' => []],
+ ],
+ ]);
+
+ // Instagram and Instagram-via-Facebook share their content types, so the
+ // stored one is still valid and must not be rewritten to the default.
+ $instagram->update(['platform' => Platform::InstagramFacebook]);
+
+ expect(data_get($repurpose->fresh()->destinations, '0.content_type'))
+ ->toBe(ContentType::InstagramReel->value);
+});
+
+test('an item whose destination account was deleted records no usable destination', function () {
+ [$workspace, $user, $source] = healthWorkspace();
+ $gone = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Threads]);
+
+ $repurpose = Repurpose::factory()->for($workspace)->create([
+ 'source_social_account_id' => $source->id,
+ 'status' => Status::Draft,
+ 'destinations' => [
+ ['social_account_id' => $gone->id, 'content_type' => ContentType::ThreadsPost->value, 'meta' => []],
+ ],
+ ]);
+
+ $item = RepurposeItem::factory()->for($repurpose)->create();
+
+ // Straight to the job with the destination still stored but the account
+ // gone: the pruning path and the job's own guard are separate defences.
+ $repurpose->update(['destinations' => $repurpose->destinations]);
+ $gone->forceDelete();
+
+ (new ProcessRepurposeItem($item, 'https://example.com/v.mp4', 'caption'))
+ ->handle(app(MediaAttacher::class), app(CaptionAdapter::class));
+
+ expect($item->fresh()->reason)->toBe(ItemReason::NoUsableDestinations);
+});
From 9a04d39791df63c2e1b376bb6ee8725ed7139116 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 21:20:51 -0300
Subject: [PATCH 071/114] Stop a page watched for feed videos from replicating
its reels
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../Repurpose/FacebookSourceFetcher.php | 13 +++-
tests/Feature/Repurpose/SourceFetcherTest.php | 71 +++++++++++++++++++
2 files changed, 81 insertions(+), 3 deletions(-)
diff --git a/app/Services/Repurpose/FacebookSourceFetcher.php b/app/Services/Repurpose/FacebookSourceFetcher.php
index 7effa5369..376d7536f 100644
--- a/app/Services/Repurpose/FacebookSourceFetcher.php
+++ b/app/Services/Repurpose/FacebookSourceFetcher.php
@@ -32,11 +32,18 @@ class FacebookSourceFetcher extends MetaSourceFetcher
*/
public function fetch(SocialAccount $account, ?CarbonInterface $since, array $formats): array
{
- $reels = in_array(SourceFormat::Reel, $formats, true)
+ $wantsReels = in_array(SourceFormat::Reel, $formats, true);
+ $wantsVideos = in_array(SourceFormat::Video, $formats, true);
+
+ // The reels edge is read whenever videos are wanted, even if reels are
+ // not: /videos lists reels too and carries nothing to tell them apart,
+ // so this is the only way to subtract them. One extra call per poll,
+ // and only for a page watched for feed videos.
+ $reels = $wantsReels || $wantsVideos
? $this->videos($account, 'video_reels', $since, SourceFormat::Reel)
: [];
- $videos = in_array(SourceFormat::Video, $formats, true)
+ $videos = $wantsVideos
? $this->videos($account, 'videos', $since, SourceFormat::Video)
: [];
@@ -52,7 +59,7 @@ public function fetch(SocialAccount $account, ?CarbonInterface $since, array $fo
));
}
- return [...$reels, ...$videos, ...$stories];
+ return [...($wantsReels ? $reels : []), ...$videos, ...$stories];
}
/**
diff --git a/tests/Feature/Repurpose/SourceFetcherTest.php b/tests/Feature/Repurpose/SourceFetcherTest.php
index 1f98a1cef..e90dcc4f1 100644
--- a/tests/Feature/Repurpose/SourceFetcherTest.php
+++ b/tests/Feature/Repurpose/SourceFetcherTest.php
@@ -247,3 +247,74 @@ function fetchFor(SocialAccount $account, array $formats, $since = null): array
Http::assertSent(fn ($request) => str_contains((string) $request->url(), 'limit=25')
&& str_contains((string) $request->url(), 'since='.$since->getTimestamp()));
});
+
+test('a page watched for feed videos alone does not pick up its reels', function () {
+ Http::fake([
+ facebookGraph().'/*/video_reels*' => Http::response(['data' => [
+ ['id' => 'v1', 'source' => 'https://cdn.example.com/r.mp4', 'description' => 'Reel', 'permalink_url' => '/watch/1', 'created_time' => '2026-09-02T10:00:00+0000'],
+ ]]),
+ facebookGraph().'/*/videos*' => Http::response(['data' => [
+ ['id' => 'v1', 'source' => 'https://cdn.example.com/r.mp4', 'description' => 'Reel', 'permalink_url' => '/watch/1', 'created_time' => '2026-09-02T10:00:00+0000'],
+ ['id' => 'v2', 'source' => 'https://cdn.example.com/v.mp4', 'description' => 'Video', 'permalink_url' => '/watch/2', 'created_time' => '2026-09-02T11:00:00+0000'],
+ ]]),
+ ]);
+
+ $account = SocialAccount::factory()->create(['platform' => Platform::Facebook]);
+
+ $media = fetchFor($account, [SourceFormat::Video]);
+
+ expect($media)->toHaveCount(1)
+ ->and($media[0]->id)->toBe('v2');
+});
+
+test('a token that cannot read a field falls back to the public set', function () {
+ $attempt = 0;
+
+ Http::fake([
+ instagramGraph().'/*/media*' => function () use (&$attempt) {
+ $attempt++;
+
+ // Graph rejects the whole read when one requested field is not
+ // available to the token's login type, answering with code 100.
+ return $attempt === 1
+ ? Http::response(['error' => ['code' => 100, 'message' => 'Unsupported get request']], 400)
+ : Http::response(['data' => [[
+ 'id' => 'm1',
+ 'media_type' => 'VIDEO',
+ 'media_url' => 'https://cdn.example.com/v.mp4',
+ 'permalink' => 'https://instagram.com/p/1',
+ 'timestamp' => '2026-09-02T10:00:00+0000',
+ ]]]);
+ },
+ ]);
+
+ $account = SocialAccount::factory()->create(['platform' => Platform::Instagram]);
+
+ $media = fetchFor($account, [SourceFormat::Reel]);
+
+ expect($attempt)->toBe(2)
+ ->and($media)->toHaveCount(1)
+ ->and($media[0]->id)->toBe('m1')
+ // The reduced set carries neither media_product_type nor caption, so
+ // every video reads as a Reel and the caption arrives empty. That is the
+ // documented cost of not going dark, not an oversight.
+ ->and($media[0]->format)->toBe(SourceFormat::Reel)
+ ->and($media[0]->caption)->toBe('');
+});
+
+test('a graph failure that is not an unknown field is not retried', function () {
+ $attempt = 0;
+
+ Http::fake([
+ instagramGraph().'/*/media*' => function () use (&$attempt) {
+ $attempt++;
+
+ return Http::response(['error' => ['code' => 190, 'message' => 'Invalid token']], 400);
+ },
+ ]);
+
+ $account = SocialAccount::factory()->create(['platform' => Platform::Instagram]);
+
+ expect(fn () => fetchFor($account, [SourceFormat::Reel]))->toThrow(SourceFetchException::class)
+ ->and($attempt)->toBe(1);
+});
From 2824ecb9f3020dc8b2f2b420e5818b52bac237df Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 21:31:57 -0300
Subject: [PATCH 072/114] Cover the account-health surface on the API and MCP
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
tests/Feature/Api/RepurposeApiTest.php | 40 +++++++++++++++++++++++++
tests/Feature/Mcp/RepurposeToolTest.php | 34 +++++++++++++++++++++
2 files changed, 74 insertions(+)
diff --git a/tests/Feature/Api/RepurposeApiTest.php b/tests/Feature/Api/RepurposeApiTest.php
index 77ad06e35..1fbdc2fad 100644
--- a/tests/Feature/Api/RepurposeApiTest.php
+++ b/tests/Feature/Api/RepurposeApiTest.php
@@ -4,10 +4,12 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\Repurpose\ItemStatus;
+use App\Enums\Repurpose\PauseReason;
use App\Enums\Repurpose\PublishMode;
use App\Enums\Repurpose\SourceFormat;
use App\Enums\Repurpose\Status;
use App\Enums\SocialAccount\Platform;
+use App\Enums\SocialAccount\Status as AccountStatus;
use App\Models\Repurpose;
use App\Models\RepurposeItem;
use App\Models\SocialAccount;
@@ -282,3 +284,41 @@ function tiktokDestinationPayload(SocialAccount $account): array
->putJson(route('api.repurposes.update', $created->json('id')), ['publish_mode' => 'whenever'])
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
});
+
+test('the api exposes why a repurpose stopped and refuses to resume it while broken', function () {
+ $repurpose = Repurpose::factory()->for($this->workspace)->create([
+ 'source_social_account_id' => $this->source->id,
+ 'status' => Status::Paused,
+ 'paused_reason' => PauseReason::SourceUnavailable,
+ 'destinations' => [tiktokDestinationPayload($this->tiktok)],
+ ]);
+
+ $this->source->update(['status' => AccountStatus::Disconnected]);
+
+ $this->withHeaders(apiHeaders($this->token))
+ ->getJson(route('api.repurposes.show', $repurpose))
+ ->assertOk()
+ ->assertJsonPath('paused_reason', PauseReason::SourceUnavailable->value);
+
+ // The health gate lives in the action, so every surface inherits it.
+ $this->withHeaders(apiHeaders($this->token))
+ ->postJson(route('api.repurposes.resume', $repurpose))
+ ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
+ ->assertJsonValidationErrors('source_social_account_id');
+});
+
+test('the api accepts a switched-off account as a destination', function () {
+ $repurpose = Repurpose::factory()->for($this->workspace)->create([
+ 'source_social_account_id' => $this->source->id,
+ ]);
+
+ $this->tiktok->update(['is_active' => false]);
+
+ $this->withHeaders(apiHeaders($this->token))
+ ->putJson(route('api.repurposes.update', $repurpose), [
+ 'destinations' => [tiktokDestinationPayload($this->tiktok)],
+ ])
+ ->assertOk();
+
+ expect($repurpose->fresh()->destinations)->toHaveCount(1);
+});
diff --git a/tests/Feature/Mcp/RepurposeToolTest.php b/tests/Feature/Mcp/RepurposeToolTest.php
index f40fb7eae..f1790c886 100644
--- a/tests/Feature/Mcp/RepurposeToolTest.php
+++ b/tests/Feature/Mcp/RepurposeToolTest.php
@@ -4,10 +4,12 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\Repurpose\ItemStatus;
+use App\Enums\Repurpose\PauseReason;
use App\Enums\Repurpose\PublishMode;
use App\Enums\Repurpose\SourceFormat;
use App\Enums\Repurpose\Status;
use App\Enums\SocialAccount\Platform;
+use App\Enums\SocialAccount\Status as AccountStatus;
use App\Enums\UserWorkspace\Role;
use App\Mcp\Servers\TryPostServer;
use App\Mcp\Tools\Repurpose\ActivateRepurposeTool;
@@ -268,3 +270,35 @@ function tiktokDestinationForMcp(SocialAccount $account): array
expect(Repurpose::where('workspace_id', $this->workspace->id)->sole()->publish_mode)
->toBe(PublishMode::Draft);
});
+
+test('the get tool reports why a repurpose stopped', function () {
+ $repurpose = Repurpose::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'source_social_account_id' => $this->source->id,
+ 'status' => Status::Paused,
+ 'paused_reason' => PauseReason::SourceRemoved,
+ 'destinations' => [tiktokDestinationForMcp($this->tiktok)],
+ ]);
+
+ TryPostServer::actingAs($this->user)
+ ->tool(GetRepurposeTool::class, ['repurpose_id' => $repurpose->id])
+ ->assertOk()
+ ->assertSee(PauseReason::SourceRemoved->value);
+});
+
+test('activating through the tool is refused while the source is unusable', function () {
+ $repurpose = Repurpose::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'source_social_account_id' => $this->source->id,
+ 'destinations' => [tiktokDestinationForMcp($this->tiktok)],
+ ]);
+
+ $this->source->update(['status' => AccountStatus::Disconnected]);
+
+ // The gate lives in the action, so the tool inherits it without knowing.
+ TryPostServer::actingAs($this->user)
+ ->tool(ActivateRepurposeTool::class, ['repurpose_id' => $repurpose->id])
+ ->assertHasErrors();
+
+ expect($repurpose->fresh()->status)->toBe(Status::Draft);
+});
From 4ed6e7ab958621f6f3ebabace7c420c89706d95f Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 21:57:17 -0300
Subject: [PATCH 073/114] Serve the activity list from one query instead of
three
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/Actions/Repurpose/ListRepurposeItems.php | 10 +++++--
.../Controllers/Api/RepurposeController.php | 12 ++++----
.../Repurpose/ListRepurposeItemsTool.php | 7 ++---
tests/Feature/Api/RepurposeApiTest.php | 26 +++++++++++++++++
tests/Feature/Mcp/RepurposeToolTest.php | 29 +++++++++++++++++++
5 files changed, 71 insertions(+), 13 deletions(-)
diff --git a/app/Actions/Repurpose/ListRepurposeItems.php b/app/Actions/Repurpose/ListRepurposeItems.php
index 6c27b641d..ba415ab85 100644
--- a/app/Actions/Repurpose/ListRepurposeItems.php
+++ b/app/Actions/Repurpose/ListRepurposeItems.php
@@ -12,13 +12,19 @@
class ListRepurposeItems
{
/**
+ * The one place the activity query lives. It was duplicated in the API
+ * controller and the MCP tool, and both kept an eager load that had since
+ * gained a column — so the resource read a status the query never selected.
+ *
+ * @param int|null $perPage Only the public API passes this: its page size
+ * is a documented contract, not the app default.
* @return LengthAwarePaginator
*/
- public static function execute(Repurpose $repurpose): LengthAwarePaginator
+ public static function execute(Repurpose $repurpose, ?int $page = null, ?int $perPage = null): LengthAwarePaginator
{
return $repurpose->items()
->with('posts.postPlatforms:id,post_id,platform,enabled,status')
->orderByDesc(DB::raw('coalesce(source_created_at, created_at)'))
- ->paginate((int) config('app.pagination.default'));
+ ->paginate($perPage ?? (int) config('app.pagination.default'), page: $page);
}
}
diff --git a/app/Http/Controllers/Api/RepurposeController.php b/app/Http/Controllers/Api/RepurposeController.php
index c8a0afe89..2f0a9b9af 100644
--- a/app/Http/Controllers/Api/RepurposeController.php
+++ b/app/Http/Controllers/Api/RepurposeController.php
@@ -8,6 +8,7 @@
use App\Actions\Repurpose\CreateRepurpose;
use App\Actions\Repurpose\DeleteRepurpose;
use App\Actions\Repurpose\DisableRepurpose;
+use App\Actions\Repurpose\ListRepurposeItems;
use App\Actions\Repurpose\PauseRepurpose;
use App\Actions\Repurpose\ResumeRepurpose;
use App\Actions\Repurpose\UpdateRepurpose;
@@ -22,11 +23,13 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
-use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class RepurposeController extends Controller
{
+ /** The public API's page size is a documented contract, not the app default. */
+ private const PAGE_SIZE = 15;
+
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Repurpose::class);
@@ -37,7 +40,7 @@ public function index(Request $request): AnonymousResourceCollection
->with('sourceAccount')
->withCount(['items as published_items_count' => fn ($query) => $query->where('status', ItemStatus::Published)])
->latest()
- ->paginate(15),
+ ->paginate(self::PAGE_SIZE),
);
}
@@ -112,10 +115,7 @@ public function items(Request $request, Repurpose $repurpose): AnonymousResource
$this->authorize('view', $repurpose);
return RepurposeItemResource::collection(
- $repurpose->items()
- ->with('posts.postPlatforms:id,post_id,platform,enabled')
- ->orderByDesc(DB::raw('coalesce(source_created_at, created_at)'))
- ->paginate(15),
+ ListRepurposeItems::execute($repurpose, perPage: self::PAGE_SIZE),
);
}
diff --git a/app/Mcp/Tools/Repurpose/ListRepurposeItemsTool.php b/app/Mcp/Tools/Repurpose/ListRepurposeItemsTool.php
index 380614815..754c3c947 100644
--- a/app/Mcp/Tools/Repurpose/ListRepurposeItemsTool.php
+++ b/app/Mcp/Tools/Repurpose/ListRepurposeItemsTool.php
@@ -4,6 +4,7 @@
namespace App\Mcp\Tools\Repurpose;
+use App\Actions\Repurpose\ListRepurposeItems;
use App\Http\Resources\Api\RepurposeItemResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Mcp\Concerns\ResolvesWorkspaceRepurpose;
@@ -11,7 +12,6 @@
use App\Models\Repurpose;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
-use Illuminate\Support\Facades\DB;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
@@ -40,10 +40,7 @@ public function handle(Request $request): Response|ResponseFactory
return $repurpose;
}
- $items = $repurpose->items()
- ->with('posts.postPlatforms:id,post_id,platform,enabled')
- ->orderByDesc(DB::raw('coalesce(source_created_at, created_at)'))
- ->paginate((int) config('app.pagination.default'), page: (int) data_get($validated, 'page', 1));
+ $items = ListRepurposeItems::execute($repurpose, page: (int) data_get($validated, 'page', 1));
return Response::structured([
'items' => RepurposeItemResource::collection($items->items())->resolve(),
diff --git a/tests/Feature/Api/RepurposeApiTest.php b/tests/Feature/Api/RepurposeApiTest.php
index 1fbdc2fad..09305554f 100644
--- a/tests/Feature/Api/RepurposeApiTest.php
+++ b/tests/Feature/Api/RepurposeApiTest.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
+use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Enums\Repurpose\ItemStatus;
use App\Enums\Repurpose\PauseReason;
use App\Enums\Repurpose\PublishMode;
@@ -10,6 +11,8 @@
use App\Enums\Repurpose\Status;
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status as AccountStatus;
+use App\Models\Post;
+use App\Models\PostPlatform;
use App\Models\Repurpose;
use App\Models\RepurposeItem;
use App\Models\SocialAccount;
@@ -322,3 +325,26 @@ function tiktokDestinationPayload(SocialAccount $account): array
expect($repurpose->fresh()->destinations)->toHaveCount(1);
});
+
+test('the api activity list carries each replicated post status', function () {
+ $repurpose = Repurpose::factory()->for($this->workspace)->create([
+ 'source_social_account_id' => $this->source->id,
+ ]);
+
+ $item = RepurposeItem::factory()->for($repurpose)->create();
+
+ $post = Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'repurpose_item_id' => $item->id,
+ ]);
+ PostPlatform::factory()->for($post)->create([
+ 'platform' => Platform::TikTok,
+ 'enabled' => true,
+ 'status' => PostPlatformStatus::Published,
+ ]);
+
+ $this->withHeaders(apiHeaders($this->token))
+ ->getJson(route('api.repurposes.items', $repurpose))
+ ->assertOk()
+ ->assertJsonPath('data.0.posts.0.platforms.0.status', PostPlatformStatus::Published->value);
+});
diff --git a/tests/Feature/Mcp/RepurposeToolTest.php b/tests/Feature/Mcp/RepurposeToolTest.php
index f1790c886..b56b58fc8 100644
--- a/tests/Feature/Mcp/RepurposeToolTest.php
+++ b/tests/Feature/Mcp/RepurposeToolTest.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
+use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Enums\Repurpose\ItemStatus;
use App\Enums\Repurpose\PauseReason;
use App\Enums\Repurpose\PublishMode;
@@ -21,6 +22,8 @@
use App\Mcp\Tools\Repurpose\ListRepurposeTemplatesTool;
use App\Mcp\Tools\Repurpose\PauseRepurposeTool;
use App\Mcp\Tools\Repurpose\UpdateRepurposeTool;
+use App\Models\Post;
+use App\Models\PostPlatform;
use App\Models\Repurpose;
use App\Models\RepurposeItem;
use App\Models\SocialAccount;
@@ -302,3 +305,29 @@ function tiktokDestinationForMcp(SocialAccount $account): array
expect($repurpose->fresh()->status)->toBe(Status::Draft);
});
+
+test('the items tool carries each replicated post status', function () {
+ $repurpose = Repurpose::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'source_social_account_id' => $this->source->id,
+ ]);
+
+ $item = RepurposeItem::factory()->for($repurpose)->create();
+
+ $post = Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'repurpose_item_id' => $item->id,
+ ]);
+ PostPlatform::factory()->for($post)->create([
+ 'platform' => Platform::TikTok,
+ 'enabled' => true,
+ 'status' => PostPlatformStatus::Published,
+ ]);
+
+ // Shares ListRepurposeItems with the web and the API, so the eager load can
+ // no longer drift out of step with what the resource reads.
+ TryPostServer::actingAs($this->user)
+ ->tool(ListRepurposeItemsTool::class, ['repurpose_id' => $repurpose->id])
+ ->assertOk()
+ ->assertSee(PostPlatformStatus::Published->value);
+});
From 606e926f9b7d9cb4e63691024ac77241dfca1f67 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 22:11:02 -0300
Subject: [PATCH 074/114] Serve the repurpose list from its action too
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/Actions/Repurpose/ListRepurposes.php | 9 +++++++--
app/Http/Controllers/Api/RepurposeController.php | 9 ++-------
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/app/Actions/Repurpose/ListRepurposes.php b/app/Actions/Repurpose/ListRepurposes.php
index c7e9543a2..2dd40a754 100644
--- a/app/Actions/Repurpose/ListRepurposes.php
+++ b/app/Actions/Repurpose/ListRepurposes.php
@@ -12,15 +12,20 @@
class ListRepurposes
{
/**
+ * The one place the list query lives. Duplicating it is how the activity
+ * list ended up serving a column two of its three callers never selected.
+ *
+ * @param int|null $perPage Only the public API passes this: its page size
+ * is a documented contract, not the app default.
* @return LengthAwarePaginator
*/
- public static function execute(Workspace $workspace, ?int $page = null): LengthAwarePaginator
+ public static function execute(Workspace $workspace, ?int $page = null, ?int $perPage = null): LengthAwarePaginator
{
return Repurpose::query()
->where('workspace_id', $workspace->id)
->with('sourceAccount')
->withCount(['items as published_items_count' => fn ($query) => $query->where('status', ItemStatus::Published)])
->latest()
- ->paginate((int) config('app.pagination.default'), page: $page);
+ ->paginate($perPage ?? (int) config('app.pagination.default'), page: $page);
}
}
diff --git a/app/Http/Controllers/Api/RepurposeController.php b/app/Http/Controllers/Api/RepurposeController.php
index 2f0a9b9af..09ade9599 100644
--- a/app/Http/Controllers/Api/RepurposeController.php
+++ b/app/Http/Controllers/Api/RepurposeController.php
@@ -9,10 +9,10 @@
use App\Actions\Repurpose\DeleteRepurpose;
use App\Actions\Repurpose\DisableRepurpose;
use App\Actions\Repurpose\ListRepurposeItems;
+use App\Actions\Repurpose\ListRepurposes;
use App\Actions\Repurpose\PauseRepurpose;
use App\Actions\Repurpose\ResumeRepurpose;
use App\Actions\Repurpose\UpdateRepurpose;
-use App\Enums\Repurpose\ItemStatus;
use App\Enums\Repurpose\SourceFormat;
use App\Http\Requests\Api\Repurpose\StoreRepurposeRequest;
use App\Http\Requests\Api\Repurpose\UpdateRepurposeRequest;
@@ -35,12 +35,7 @@ public function index(Request $request): AnonymousResourceCollection
$this->authorize('viewAny', Repurpose::class);
return RepurposeResource::collection(
- $request->user()->currentWorkspace
- ->repurposes()
- ->with('sourceAccount')
- ->withCount(['items as published_items_count' => fn ($query) => $query->where('status', ItemStatus::Published)])
- ->latest()
- ->paginate(self::PAGE_SIZE),
+ ListRepurposes::execute($request->user()->currentWorkspace, perPage: self::PAGE_SIZE),
);
}
From d65dbcff69d551ae4e195a5e4f029b62f1f2156c Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 22:13:47 -0300
Subject: [PATCH 075/114] Stop an exhausted item leaving orphan drafts in the
calendar
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.
---
app/Jobs/Repurpose/ProcessRepurposeItem.php | 20 ++++++++++++
tests/Feature/Repurpose/ProcessItemTest.php | 36 +++++++++++++++++++++
2 files changed, 56 insertions(+)
diff --git a/app/Jobs/Repurpose/ProcessRepurposeItem.php b/app/Jobs/Repurpose/ProcessRepurposeItem.php
index bbe0c051c..87cbbc5f8 100644
--- a/app/Jobs/Repurpose/ProcessRepurposeItem.php
+++ b/app/Jobs/Repurpose/ProcessRepurposeItem.php
@@ -140,8 +140,28 @@ public function handle(MediaAttacher $media, CaptionAdapter $captions): void
$this->item->update(['status' => ItemStatus::Published, 'reason' => null, 'error' => null]);
}
+ /**
+ * An attempt that died after creating some of its posts leaves them behind
+ * as drafts. Every retry clears them on the way in, but the last one has no
+ * successor — so without this they sit in the calendar with nothing
+ * explaining where they came from.
+ *
+ * In draft mode the draft is the deliverable, not a leftover: the user can
+ * already see and publish it, so a late failure keeps it and the item says
+ * what actually happened.
+ */
public function failed(Throwable $exception): void
{
+ $drafts = $this->item->posts()->where('status', PostStatus::Draft)->get();
+
+ if ($drafts->isNotEmpty() && $this->item->repurpose?->publish_mode === PublishMode::Draft) {
+ $this->item->update(['status' => ItemStatus::Drafted, 'reason' => null, 'error' => null]);
+
+ return;
+ }
+
+ $drafts->each(fn (Post $post) => $post->forceDelete());
+
$this->item->update([
'status' => ItemStatus::Failed,
'reason' => $exception instanceof SourceDownloadException ? ItemReason::DownloadFailed : $this->item->reason,
diff --git a/tests/Feature/Repurpose/ProcessItemTest.php b/tests/Feature/Repurpose/ProcessItemTest.php
index 30e1e4c95..d7001f0a4 100644
--- a/tests/Feature/Repurpose/ProcessItemTest.php
+++ b/tests/Feature/Repurpose/ProcessItemTest.php
@@ -433,3 +433,39 @@ function processItem(RepurposeItem $item, string $caption = 'My caption'): void
expect($item->fresh()->status)->toBe(ItemStatus::Drafted);
});
+
+test('an exhausted publish-mode item leaves no orphan drafts behind', function () {
+ $item = repurposeWithTwoDestinations();
+
+ // What an attempt that died after creating its posts leaves behind.
+ $post = Post::factory()->create([
+ 'workspace_id' => $item->repurpose->workspace_id,
+ 'repurpose_item_id' => $item->id,
+ 'status' => PostStatus::Draft,
+ ]);
+
+ (new ProcessRepurposeItem($item, REPURPOSE_VIDEO_URL, 'caption'))
+ ->failed(new RuntimeException('gave up'));
+
+ expect(Post::query()->whereKey($post->id)->exists())->toBeFalse()
+ ->and($item->fresh()->status)->toBe(ItemStatus::Failed);
+});
+
+test('an exhausted draft-mode item keeps its drafts and says it drafted them', function () {
+ $item = repurposeWithTwoDestinations();
+ $item->repurpose->update(['publish_mode' => PublishMode::Draft]);
+
+ $post = Post::factory()->create([
+ 'workspace_id' => $item->repurpose->workspace_id,
+ 'repurpose_item_id' => $item->id,
+ 'status' => PostStatus::Draft,
+ ]);
+
+ // In draft mode the draft is the deliverable, so a late failure must not
+ // throw away work the user can already see and publish.
+ (new ProcessRepurposeItem($item, REPURPOSE_VIDEO_URL, 'caption'))
+ ->failed(new RuntimeException('gave up'));
+
+ expect(Post::query()->whereKey($post->id)->exists())->toBeTrue()
+ ->and($item->fresh()->status)->toBe(ItemStatus::Drafted);
+});
From abf1a6600c87618c20713fa04f02e31bea208bd1 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 22:16:00 -0300
Subject: [PATCH 076/114] Keep the signed source URL out of the stored item
error
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/Jobs/Repurpose/ProcessRepurposeItem.php | 18 +++++++++++++++++-
tests/Feature/Repurpose/ProcessItemTest.php | 14 ++++++++++++++
2 files changed, 31 insertions(+), 1 deletion(-)
diff --git a/app/Jobs/Repurpose/ProcessRepurposeItem.php b/app/Jobs/Repurpose/ProcessRepurposeItem.php
index 87cbbc5f8..f9df55c63 100644
--- a/app/Jobs/Repurpose/ProcessRepurposeItem.php
+++ b/app/Jobs/Repurpose/ProcessRepurposeItem.php
@@ -15,6 +15,7 @@
use App\Models\RepurposeItem;
use App\Services\Post\MediaAttacher;
use App\Services\Repurpose\CaptionAdapter;
+use App\Services\Social\TokenRedactor;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -165,10 +166,25 @@ public function failed(Throwable $exception): void
$this->item->update([
'status' => ItemStatus::Failed,
'reason' => $exception instanceof SourceDownloadException ? ItemReason::DownloadFailed : $this->item->reason,
- 'error' => Str::limit($exception->getMessage(), 1000),
+ 'error' => $this->safeError($exception),
]);
}
+ /**
+ * The item's error is read by humans in the app and served through the
+ * public API and MCP, so it must not carry a credential. A CDN download URL
+ * is one: Meta signs it with expiring oh/oe parameters, and an HTTP client
+ * puts the whole URL in its message. The job knows exactly which string that
+ * is, so it is replaced rather than pattern-matched, and TokenRedactor still
+ * covers the OAuth shapes it already knows.
+ */
+ private function safeError(Throwable $exception): string
+ {
+ $message = str_replace($this->downloadUrl, '[source url]', $exception->getMessage());
+
+ return Str::limit((string) TokenRedactor::redact($message), 1000);
+ }
+
/**
* Throws so the job's own retries get a chance at it: a source video that is
* not downloadable right now usually is minutes later. {@see self::failed()}
diff --git a/tests/Feature/Repurpose/ProcessItemTest.php b/tests/Feature/Repurpose/ProcessItemTest.php
index d7001f0a4..568b813b4 100644
--- a/tests/Feature/Repurpose/ProcessItemTest.php
+++ b/tests/Feature/Repurpose/ProcessItemTest.php
@@ -469,3 +469,17 @@ function processItem(RepurposeItem $item, string $caption = 'My caption'): void
expect(Post::query()->whereKey($post->id)->exists())->toBeTrue()
->and($item->fresh()->status)->toBe(ItemStatus::Drafted);
});
+
+test('the stored error never carries the signed source url', function () {
+ $item = repurposeWithTwoDestinations();
+
+ // A CDN download URL is a short-lived credential: Meta signs it with oh/oe
+ // query parameters. Guzzle puts the whole URL in its message, and the item
+ // error is exposed through the UI, the public API and MCP.
+ $message = 'cURL error 28: Operation timed out for '.REPURPOSE_VIDEO_URL.'?oh=SECRETSIG&oe=68B0';
+
+ (new ProcessRepurposeItem($item, REPURPOSE_VIDEO_URL.'?oh=SECRETSIG&oe=68B0', 'caption'))
+ ->failed(new RuntimeException($message));
+
+ expect($item->fresh()->error)->not->toContain('SECRETSIG');
+});
From 94c8632fd6d7abed86c69de41a90041746a3b87c Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Sun, 6 Sep 2026 22:19:15 -0300
Subject: [PATCH 077/114] Stop an orphaned repurpose claiming it watches
LinkedIn
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
lang/ar/repurposes.php | 1 +
lang/de/repurposes.php | 1 +
lang/el/repurposes.php | 1 +
lang/en/repurposes.php | 1 +
lang/es/repurposes.php | 1 +
lang/fr/repurposes.php | 1 +
lang/it/repurposes.php | 1 +
lang/ja/repurposes.php | 1 +
lang/ko/repurposes.php | 1 +
lang/nl/repurposes.php | 1 +
lang/pl/repurposes.php | 1 +
lang/pt-BR/repurposes.php | 1 +
lang/ru/repurposes.php | 1 +
lang/tr/repurposes.php | 1 +
lang/uk/repurposes.php | 1 +
lang/zh/repurposes.php | 1 +
.../js/components/repurpose/RepurposeFlow.vue | 19 ++++++++++++++++---
tests/Browser/RepurposeAccountHealthTest.php | 3 +++
18 files changed, 35 insertions(+), 3 deletions(-)
diff --git a/lang/ar/repurposes.php b/lang/ar/repurposes.php
index e92c98a65..9d58f3621 100644
--- a/lang/ar/repurposes.php
+++ b/lang/ar/repurposes.php
@@ -8,6 +8,7 @@
'new' => 'repurpose جديد',
'flow' => [
+ 'no_source' => 'لا يوجد حساب مصدر',
'no_destinations' => 'لا توجد وجهة بعد',
],
diff --git a/lang/de/repurposes.php b/lang/de/repurposes.php
index 2e4a78a5d..0e4f1e4fa 100644
--- a/lang/de/repurposes.php
+++ b/lang/de/repurposes.php
@@ -8,6 +8,7 @@
'new' => 'Neues Repurpose',
'flow' => [
+ 'no_source' => 'Kein Quellkonto',
'no_destinations' => 'Noch kein Ziel',
],
diff --git a/lang/el/repurposes.php b/lang/el/repurposes.php
index 1c9165214..08f9676ee 100644
--- a/lang/el/repurposes.php
+++ b/lang/el/repurposes.php
@@ -8,6 +8,7 @@
'new' => 'Νέο repurpose',
'flow' => [
+ 'no_source' => 'Χωρίς λογαριασμό προέλευσης',
'no_destinations' => 'Κανένας προορισμός ακόμη',
],
diff --git a/lang/en/repurposes.php b/lang/en/repurposes.php
index 7c87c1fc7..e174607f5 100644
--- a/lang/en/repurposes.php
+++ b/lang/en/repurposes.php
@@ -8,6 +8,7 @@
'new' => 'New repurpose',
'flow' => [
+ 'no_source' => 'No source account',
'no_destinations' => 'No destination yet',
],
diff --git a/lang/es/repurposes.php b/lang/es/repurposes.php
index 8d7909bdd..453d59bec 100644
--- a/lang/es/repurposes.php
+++ b/lang/es/repurposes.php
@@ -8,6 +8,7 @@
'new' => 'Nuevo repurpose',
'flow' => [
+ 'no_source' => 'Sin cuenta de origen',
'no_destinations' => 'Aún sin destino',
],
diff --git a/lang/fr/repurposes.php b/lang/fr/repurposes.php
index e23a3a5fd..767d2082b 100644
--- a/lang/fr/repurposes.php
+++ b/lang/fr/repurposes.php
@@ -8,6 +8,7 @@
'new' => 'Nouveau repurpose',
'flow' => [
+ 'no_source' => 'Aucun compte source',
'no_destinations' => 'Aucune destination',
],
diff --git a/lang/it/repurposes.php b/lang/it/repurposes.php
index d5252e107..08a2e82b6 100644
--- a/lang/it/repurposes.php
+++ b/lang/it/repurposes.php
@@ -8,6 +8,7 @@
'new' => 'Nuovo repurpose',
'flow' => [
+ 'no_source' => 'Nessun account di origine',
'no_destinations' => 'Nessuna destinazione',
],
diff --git a/lang/ja/repurposes.php b/lang/ja/repurposes.php
index 8db702171..db5a830a8 100644
--- a/lang/ja/repurposes.php
+++ b/lang/ja/repurposes.php
@@ -8,6 +8,7 @@
'new' => '新しい Repurpose',
'flow' => [
+ 'no_source' => 'ソースアカウントなし',
'no_destinations' => '配信先はまだありません',
],
diff --git a/lang/ko/repurposes.php b/lang/ko/repurposes.php
index e80025d0e..cb4c129f4 100644
--- a/lang/ko/repurposes.php
+++ b/lang/ko/repurposes.php
@@ -8,6 +8,7 @@
'new' => '새 Repurpose',
'flow' => [
+ 'no_source' => '소스 계정 없음',
'no_destinations' => '아직 대상이 없습니다',
],
diff --git a/lang/nl/repurposes.php b/lang/nl/repurposes.php
index ea5aacc9b..c805c8c50 100644
--- a/lang/nl/repurposes.php
+++ b/lang/nl/repurposes.php
@@ -8,6 +8,7 @@
'new' => 'Nieuwe repurpose',
'flow' => [
+ 'no_source' => 'Geen bronaccount',
'no_destinations' => 'Nog geen bestemming',
],
diff --git a/lang/pl/repurposes.php b/lang/pl/repurposes.php
index e00dfdf37..b26f29cfc 100644
--- a/lang/pl/repurposes.php
+++ b/lang/pl/repurposes.php
@@ -8,6 +8,7 @@
'new' => 'Nowy repurpose',
'flow' => [
+ 'no_source' => 'Brak konta źródłowego',
'no_destinations' => 'Brak celu',
],
diff --git a/lang/pt-BR/repurposes.php b/lang/pt-BR/repurposes.php
index f34186b59..fb081b429 100644
--- a/lang/pt-BR/repurposes.php
+++ b/lang/pt-BR/repurposes.php
@@ -8,6 +8,7 @@
'new' => 'Novo repurpose',
'flow' => [
+ 'no_source' => 'Sem conta de origem',
'no_destinations' => 'Nenhum destino ainda',
],
diff --git a/lang/ru/repurposes.php b/lang/ru/repurposes.php
index 2f205c32b..2a61518a1 100644
--- a/lang/ru/repurposes.php
+++ b/lang/ru/repurposes.php
@@ -8,6 +8,7 @@
'new' => 'Новый repurpose',
'flow' => [
+ 'no_source' => 'Нет исходного аккаунта',
'no_destinations' => 'Пока нет назначения',
],
diff --git a/lang/tr/repurposes.php b/lang/tr/repurposes.php
index cdaafb4a1..03aeb58ba 100644
--- a/lang/tr/repurposes.php
+++ b/lang/tr/repurposes.php
@@ -8,6 +8,7 @@
'new' => 'Yeni repurpose',
'flow' => [
+ 'no_source' => 'Kaynak hesap yok',
'no_destinations' => 'Henüz hedef yok',
],
diff --git a/lang/uk/repurposes.php b/lang/uk/repurposes.php
index 57e251abc..ee9a7b1e7 100644
--- a/lang/uk/repurposes.php
+++ b/lang/uk/repurposes.php
@@ -8,6 +8,7 @@
'new' => 'Новий repurpose',
'flow' => [
+ 'no_source' => 'Немає вихідного облікового запису',
'no_destinations' => 'Ще немає призначення',
],
diff --git a/lang/zh/repurposes.php b/lang/zh/repurposes.php
index a33ee8d81..38c003c2c 100644
--- a/lang/zh/repurposes.php
+++ b/lang/zh/repurposes.php
@@ -8,6 +8,7 @@
'new' => '新建 Repurpose',
'flow' => [
+ 'no_source' => '没有来源账号',
'no_destinations' => '还没有目标',
],
diff --git a/resources/js/components/repurpose/RepurposeFlow.vue b/resources/js/components/repurpose/RepurposeFlow.vue
index 63d42aa77..7a8964423 100644
--- a/resources/js/components/repurpose/RepurposeFlow.vue
+++ b/resources/js/components/repurpose/RepurposeFlow.vue
@@ -1,5 +1,5 @@
@@ -49,12 +49,12 @@ const state = computed<'source_missing' | 'source_unusable' | 'no_destinations'
data-testid="repurpose-health-banner"
:class="[
'flex items-start gap-3 rounded-lg border px-4 py-3 text-sm',
- state === 'ready'
+ state === RepurposeHealth.Ready
? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400'
: 'border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-400',
]"
>
-
+
{{ $t(`repurposes.health.${state}`) }}
diff --git a/resources/js/components/repurpose/RepurposeItemList.vue b/resources/js/components/repurpose/RepurposeItemList.vue
index 47dcd31ef..cdb57ced5 100644
--- a/resources/js/components/repurpose/RepurposeItemList.vue
+++ b/resources/js/components/repurpose/RepurposeItemList.vue
@@ -14,6 +14,7 @@ import type { Component } from 'vue';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import date from '@/date';
import { edit } from '@/routes/app/posts';
+import { PostPlatformStatus, type PostPlatformStatusValue } from '@/types/post';
import type { RepurposeItem, RepurposeItemPost } from '@/types/repurpose';
import { RepurposeItemStatus, type RepurposeItemStatusValue } from '@/types/repurpose-status';
@@ -37,15 +38,17 @@ const detail = (item: RepurposeItem): string | null => item.error ?? null;
* worth surfacing; otherwise a single shared state only reads as settled when
* every network agrees on it.
*/
-const postState = (post: RepurposeItemPost): string | null => {
- const states = post.platforms.map((entry) => entry.status).filter((status): status is string => status !== null);
+const postState = (post: RepurposeItemPost): PostPlatformStatusValue | null => {
+ const states = post.platforms
+ .map((entry) => entry.status)
+ .filter((status): status is PostPlatformStatusValue => status !== null);
if (states.length === 0) {
return null;
}
- if (states.includes('failed')) {
- return 'failed';
+ if (states.includes(PostPlatformStatus.Failed)) {
+ return PostPlatformStatus.Failed;
}
return states.every((status) => status === states[0]) ? states[0] : null;
@@ -121,7 +124,7 @@ const postState = (post: RepurposeItemPost): string | null => {
:src="getPlatformLogo(entry.platform)"
:alt="getPlatformLabel(entry.platform)"
class="size-4 rounded-sm"
- :class="{ 'opacity-40': entry.status === 'failed' }"
+ :class="{ 'opacity-40': entry.status === PostPlatformStatus.Failed }"
/>
{{ post.platforms.map((entry) => getPlatformLabel(entry.platform)).join(', ') }}
@@ -130,7 +133,7 @@ const postState = (post: RepurposeItemPost): string | null => {
v-if="postState(post)"
:class="[
'rounded px-1 py-px text-[10px] font-semibold uppercase tracking-wide',
- postState(post) === 'failed'
+ postState(post) === PostPlatformStatus.Failed
? 'bg-red-500/10 text-red-600 dark:text-red-400'
: 'bg-foreground/10 text-foreground/60',
]"
diff --git a/resources/js/types/repurpose-status.ts b/resources/js/types/repurpose-status.ts
index 8c3432f5a..eec4a2970 100644
--- a/resources/js/types/repurpose-status.ts
+++ b/resources/js/types/repurpose-status.ts
@@ -20,6 +20,21 @@ export const PauseReason = {
export type PauseReasonValue = (typeof PauseReason)[keyof typeof PauseReason];
+/**
+ * What the page tells the user about a stopped repurpose. Derived from current
+ * account health, so it is not PauseReason: that one records why the system
+ * stopped and drives the watermark, while this describes the situation now,
+ * which may already be fixed. Each value is also its `repurposes.health.*` key.
+ */
+export const RepurposeHealth = {
+ SourceMissing: 'source_missing',
+ SourceUnusable: 'source_unusable',
+ NoDestinations: 'no_destinations',
+ Ready: 'ready',
+} as const;
+
+export type RepurposeHealthValue = (typeof RepurposeHealth)[keyof typeof RepurposeHealth];
+
export const RepurposeItemStatus = {
Pending: 'pending',
Processing: 'processing',
diff --git a/resources/js/types/repurpose.ts b/resources/js/types/repurpose.ts
index 1db8bcee2..73a98968f 100644
--- a/resources/js/types/repurpose.ts
+++ b/resources/js/types/repurpose.ts
@@ -1,4 +1,5 @@
import type { ChannelAccount } from '@/types/channel';
+import type { PostPlatformStatusValue } from '@/types/post';
import type { PauseReasonValue, RepurposeItemStatusValue, RepurposeStatusValue } from '@/types/repurpose-status';
export type RepurposeSourceFormat = 'reel' | 'video' | 'story';
@@ -49,7 +50,7 @@ export interface Repurpose {
export interface RepurposeItemPlatform {
platform: string;
- status: string | null;
+ status: PostPlatformStatusValue | null;
}
export interface RepurposeItemPost {
From 045bf84a92376622e31dc5cc3dfb1f779785c413 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 12:16:53 -0300
Subject: [PATCH 092/114] Strip the commentary out of the repurpose module
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.
---
app/Actions/Repurpose/ActivateRepurpose.php | 12 ----
app/Actions/Repurpose/ListRepurposeItems.php | 4 --
app/Actions/Repurpose/ListRepurposes.php | 3 -
app/Actions/Repurpose/ResumeRepurpose.php | 3 -
app/Actions/Repurpose/UpdateRepurpose.php | 9 ---
app/Enums/Facebook/StoryMediaType.php | 2 -
app/Enums/Facebook/StoryStatus.php | 2 -
app/Enums/Instagram/MediaProductType.php | 4 --
app/Enums/Instagram/MediaType.php | 2 -
app/Enums/Repurpose/PauseReason.php | 5 --
.../Repurpose/SourceFetchException.php | 5 --
.../Controllers/App/RepurposeController.php | 10 ---
.../Controllers/Auth/SocialController.php | 20 ------
.../Api/Repurpose/StoreRepurposeRequest.php | 6 --
.../Api/Repurpose/UpdateRepurposeRequest.php | 8 ---
.../App/Repurpose/UpdateRepurposeRequest.php | 8 ---
.../Resources/Api/RepurposeItemResource.php | 3 -
app/Jobs/Repurpose/PollRepurposeSource.php | 6 --
app/Jobs/Repurpose/ProcessRepurposeItem.php | 24 --------
.../Repurpose/CreateRepurposeRequest.php | 4 --
.../Repurpose/UpdateRepurposeRequest.php | 4 --
app/Mcp/Servers/TryPostServer.php | 1 -
app/Models/Repurpose.php | 2 -
app/Models/RepurposeItem.php | 2 -
app/Observers/SocialAccountObserver.php | 2 -
app/Policies/WorkspacePolicy.php | 4 --
app/Rules/Repurpose/SourceIsFree.php | 5 --
app/Services/Repurpose/CaptionAdapter.php | 14 -----
.../Repurpose/FacebookSourceFetcher.php | 11 ----
.../Repurpose/InstagramSourceFetcher.php | 11 ----
app/Services/Repurpose/MetaSourceFetcher.php | 11 ----
.../Repurpose/RepurposeAccountSync.php | 61 -------------------
app/Services/Social/YouTubePublisher.php | 5 --
.../Repurpose/DestinationMetaRules.php | 6 --
app/Support/Repurpose/RepurposeTransition.php | 11 ----
.../Repurpose/SourceIsNotADestination.php | 14 -----
.../posts/editor/DiscordSettings.vue | 18 ------
.../js/components/repurpose/RepurposeFlow.vue | 3 -
.../repurpose/RepurposeHealthBanner.vue | 6 --
.../repurpose/RepurposeItemList.vue | 5 --
.../components/repurpose/SourceFormatCard.vue | 6 --
resources/js/pages/repurposes/Index.vue | 2 -
resources/js/pages/repurposes/Show.vue | 16 -----
resources/js/types/channel.ts | 6 --
resources/js/types/repurpose-status.ts | 11 ----
tests/Browser/RepurposeAccountHealthTest.php | 8 ---
tests/Feature/Api/RepurposeApiTest.php | 1 -
tests/Feature/Mcp/RepurposeToolTest.php | 5 --
tests/Feature/Repurpose/AccountHealthTest.php | 29 ---------
tests/Feature/Repurpose/ActionsTest.php | 2 -
tests/Feature/Repurpose/PollingTest.php | 10 ---
tests/Feature/Repurpose/ProcessItemTest.php | 10 ---
tests/Feature/Repurpose/SourceFetcherTest.php | 5 --
.../Repurpose/SourceInvariantsTest.php | 8 ---
.../Feature/Repurpose/TranslationKeysTest.php | 4 --
tests/Feature/Repurpose/WebTest.php | 11 ----
56 files changed, 470 deletions(-)
diff --git a/app/Actions/Repurpose/ActivateRepurpose.php b/app/Actions/Repurpose/ActivateRepurpose.php
index 342328144..d584aff75 100644
--- a/app/Actions/Repurpose/ActivateRepurpose.php
+++ b/app/Actions/Repurpose/ActivateRepurpose.php
@@ -35,14 +35,8 @@ function (Repurpose $locked): void {
);
}
- /**
- * The source is all-or-nothing: without a working one there is nothing to
- * watch, so a repurpose may not run at all.
- */
public static function assertSourceUsable(Repurpose $repurpose): void
{
- // loadMissing, not a bare relation read: shouldBeStrict() is on outside
- // production, and here the model came out of a locking query.
$account = $repurpose->loadMissing('sourceAccount')->sourceAccount;
if ($account === null) {
@@ -58,12 +52,6 @@ public static function assertSourceUsable(Repurpose $repurpose): void
}
}
- /**
- * At least one, not all. A deactivated destination is the user saying
- * "don't post here", which the job already honours by skipping it —
- * demanding every destination be live would let one paused account block
- * editing and resuming every repurpose that lists it.
- */
public static function assertDestinationsPublishable(Repurpose $repurpose): void
{
if ($repurpose->destinations === []) {
diff --git a/app/Actions/Repurpose/ListRepurposeItems.php b/app/Actions/Repurpose/ListRepurposeItems.php
index 209ab3a55..30637a149 100644
--- a/app/Actions/Repurpose/ListRepurposeItems.php
+++ b/app/Actions/Repurpose/ListRepurposeItems.php
@@ -12,10 +12,6 @@
class ListRepurposeItems
{
/**
- * The one place the activity query lives. It was duplicated in the API
- * controller and the MCP tool, and both kept an eager load that had since
- * gained a column — so the resource read a status the query never selected.
- *
* @return LengthAwarePaginator
*/
public static function execute(Repurpose $repurpose, ?int $page = null): LengthAwarePaginator
diff --git a/app/Actions/Repurpose/ListRepurposes.php b/app/Actions/Repurpose/ListRepurposes.php
index 1d3f5726f..c7e9543a2 100644
--- a/app/Actions/Repurpose/ListRepurposes.php
+++ b/app/Actions/Repurpose/ListRepurposes.php
@@ -12,9 +12,6 @@
class ListRepurposes
{
/**
- * The one place the list query lives. Duplicating it is how the activity
- * list ended up serving a column two of its three callers never selected.
- *
* @return LengthAwarePaginator
*/
public static function execute(Workspace $workspace, ?int $page = null): LengthAwarePaginator
diff --git a/app/Actions/Repurpose/ResumeRepurpose.php b/app/Actions/Repurpose/ResumeRepurpose.php
index e3541523b..69d0d1ad4 100644
--- a/app/Actions/Repurpose/ResumeRepurpose.php
+++ b/app/Actions/Repurpose/ResumeRepurpose.php
@@ -22,9 +22,6 @@ function (Repurpose $locked): void {
$locked->update([
'status' => Status::Active,
- // A pause the system imposed starts fresh: replaying the
- // outage would flood the destinations with a backlog nobody
- // asked for. A pause the user chose keeps its place.
'activated_at' => $locked->paused_reason !== null ? now() : ($locked->activated_at ?? now()),
'paused_reason' => null,
'next_poll_at' => null,
diff --git a/app/Actions/Repurpose/UpdateRepurpose.php b/app/Actions/Repurpose/UpdateRepurpose.php
index 9de92c30b..d6d97e1e6 100644
--- a/app/Actions/Repurpose/UpdateRepurpose.php
+++ b/app/Actions/Repurpose/UpdateRepurpose.php
@@ -31,11 +31,6 @@ public static function execute(Repurpose $repurpose, array $data): Repurpose
$locked->fill($attributes);
- // A repurpose aimed at another account or another format has a
- // back catalogue behind it that was never meant for these
- // destinations, so the watermark moves to now instead of
- // replaying it. Asked of the locked row, so a concurrent update
- // cannot make this read the wrong "before".
if ($locked->isDirty(['source_social_account_id', 'source_format']) && $locked->activated_at !== null) {
$locked->activated_at = now();
}
@@ -44,10 +39,6 @@ public static function execute(Repurpose $repurpose, array $data): Repurpose
$locked = $locked->fresh();
if ($locked->status === Status::Active) {
- // Destinations only. The source's health is not this
- // request's business, and checking it here would fail an
- // unrelated edit during any window where the source is
- // briefly unhealthy and the observer has not caught up.
ActivateRepurpose::assertDestinationsPublishable($locked);
}
diff --git a/app/Enums/Facebook/StoryMediaType.php b/app/Enums/Facebook/StoryMediaType.php
index 6c449182f..8bf44e2ae 100644
--- a/app/Enums/Facebook/StoryMediaType.php
+++ b/app/Enums/Facebook/StoryMediaType.php
@@ -5,8 +5,6 @@
namespace App\Enums\Facebook;
/**
- * `media_type` values on GET /{page-id}/stories.
- *
* @see https://developers.facebook.com/docs/page-stories-api/
*/
enum StoryMediaType: string
diff --git a/app/Enums/Facebook/StoryStatus.php b/app/Enums/Facebook/StoryStatus.php
index 29ef7d2fa..133e05dc3 100644
--- a/app/Enums/Facebook/StoryStatus.php
+++ b/app/Enums/Facebook/StoryStatus.php
@@ -5,8 +5,6 @@
namespace App\Enums\Facebook;
/**
- * `status` values on GET /{page-id}/stories.
- *
* @see https://developers.facebook.com/docs/page-stories-api/
*/
enum StoryStatus: string
diff --git a/app/Enums/Instagram/MediaProductType.php b/app/Enums/Instagram/MediaProductType.php
index a2d6e5214..fb95136e6 100644
--- a/app/Enums/Instagram/MediaProductType.php
+++ b/app/Enums/Instagram/MediaProductType.php
@@ -5,10 +5,6 @@
namespace App\Enums\Instagram;
/**
- * `media_product_type` values on an IG Media node — the surface the media was
- * published to. Meta documents it as readable by the Facebook-login API only,
- * so a standalone Instagram account may not return it at all.
- *
* @see https://developers.facebook.com/docs/instagram-platform/reference/instagram-media/
*/
enum MediaProductType: string
diff --git a/app/Enums/Instagram/MediaType.php b/app/Enums/Instagram/MediaType.php
index d949f0f60..a232026f7 100644
--- a/app/Enums/Instagram/MediaType.php
+++ b/app/Enums/Instagram/MediaType.php
@@ -5,8 +5,6 @@
namespace App\Enums\Instagram;
/**
- * `media_type` values on an IG Media node.
- *
* @see https://developers.facebook.com/docs/instagram-platform/reference/instagram-media/
*/
enum MediaType: string
diff --git a/app/Enums/Repurpose/PauseReason.php b/app/Enums/Repurpose/PauseReason.php
index da9ac68d1..478ca95b6 100644
--- a/app/Enums/Repurpose/PauseReason.php
+++ b/app/Enums/Repurpose/PauseReason.php
@@ -4,11 +4,6 @@
namespace App\Enums\Repurpose;
-/**
- * Why a repurpose stopped. NULL means the user paused it themselves — that
- * distinction is what decides whether Resume replays the backlog or starts
- * from now.
- */
enum PauseReason: string
{
case SourceRemoved = 'source_removed';
diff --git a/app/Exceptions/Repurpose/SourceFetchException.php b/app/Exceptions/Repurpose/SourceFetchException.php
index f80431410..d1d51ae10 100644
--- a/app/Exceptions/Repurpose/SourceFetchException.php
+++ b/app/Exceptions/Repurpose/SourceFetchException.php
@@ -20,11 +20,6 @@ public function isTransient(): bool
return GraphError::isTransientFailure($this->response);
}
- /**
- * Graph rejects the whole read when one requested field is not available to
- * the token's login type, which is how it answers for the fields Meta marks
- * as Facebook-login only.
- */
public function isUnknownField(): bool
{
return (int) data_get($this->response->json(), 'error.code') === 100;
diff --git a/app/Http/Controllers/App/RepurposeController.php b/app/Http/Controllers/App/RepurposeController.php
index 77d72b10f..9bd980c40 100644
--- a/app/Http/Controllers/App/RepurposeController.php
+++ b/app/Http/Controllers/App/RepurposeController.php
@@ -159,9 +159,6 @@ private function sourceFormats(Repurpose $repurpose): array
}
/**
- * The content type a destination starts on: what the watched format maps to
- * on that network, or its first video type when the two do not line up.
- *
* @param Collection $accounts
* @return array
*/
@@ -223,11 +220,6 @@ private function sourceAccounts(Collection $accounts): Collection
}
/**
- * Switched-off accounts included on purpose. A destination the user paused
- * stays on the repurpose and is skipped at publish time; leaving it out here
- * drops it from the form, and the next save would erase a destination they
- * only meant to pause. The page marks them instead.
- *
* @return Collection
*/
private function connectedAccounts(Request $request): Collection
@@ -236,8 +228,6 @@ private function connectedAccounts(Request $request): Collection
}
/**
- * The source has to work, so this one really is active accounts only.
- *
* @param Collection $accounts
* @return Collection
*/
diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php
index 7f49abb0e..d668a2676 100644
--- a/app/Http/Controllers/Auth/SocialController.php
+++ b/app/Http/Controllers/Auth/SocialController.php
@@ -93,8 +93,6 @@ public function toggleActive(Request $request, SocialAccount $account): Redirect
abort(403);
}
- // Captured regardless of direction, and never from $account->is_active:
- // reading a column off the instance is what silently broke isUsable().
$before = $this->repurposeStatesFor($account);
ToggleSocialAccount::execute($account);
@@ -302,16 +300,6 @@ protected function popupCallback(bool $success, string $message, ?string $platfo
}
/**
- * Status per repurpose that depends on this account, either as its source
- * or as one of its destinations. Captured before the account changes —
- * before a delete especially, since the source FK is nullOnDelete — and used
- * as the baseline for whatever the observer goes on to change.
- *
- * Destinations are matched in PHP: they live in a JSON array of objects, and
- * partial-object containment needs a different candidate shape on PostgreSQL
- * than on MySQL. The row count is bounded by connected accounts times source
- * formats.
- *
* @return Collection
*/
private function repurposeStatesFor(SocialAccount $account): Collection
@@ -326,14 +314,6 @@ private function repurposeStatesFor(SocialAccount $account): Collection
}
/**
- * The observer has already done whatever it was going to do by now, so this
- * compares before and after rather than predicting either.
- *
- * With no email in this flow — the user did this deliberately, so an email
- * would be noise — the flash is the only notice that an automation stopped
- * or started, and it happens on the accounts page rather than where the
- * repurpose lives.
- *
* @param Collection $before
*/
private function flashAccountChange(string $action, Collection $before): void
diff --git a/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php b/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php
index ee179e284..66d2e2713 100644
--- a/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php
+++ b/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php
@@ -68,10 +68,6 @@ public function rules(): array
'string',
'uuid',
Rule::exists('social_accounts', 'id')
- // No is_active clause: switching an account off means
- // "don't post here", and the job already skips it. Rejecting
- // the payload would stop the user saving any edit, because
- // the editor round-trips the whole destination list.
->where('workspace_id', $this->workspaceId()),
],
'destinations.*.content_type' => [
@@ -115,8 +111,6 @@ public function attributes(): array
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
- // Only once the field rules have run: both sides are then strings
- // that passed `uuid`, instead of whatever the client posted.
if ($validator->errors()->isNotEmpty()) {
return;
}
diff --git a/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php b/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php
index c5038a571..d144e3505 100644
--- a/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php
+++ b/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php
@@ -30,13 +30,11 @@ private function workspaceId(): ?string
return $this->user()->currentWorkspace?->id;
}
- /** Route model binding resolves this, or the request never gets built. */
private function repurpose(): Repurpose
{
return $this->route('repurpose');
}
- /** The format this repurpose will watch once the request is applied. */
private function sourceFormat(): SourceFormat
{
return SourceFormat::tryFrom((string) $this->input('source_format'))
@@ -71,10 +69,6 @@ public function rules(): array
'string',
'uuid',
Rule::exists('social_accounts', 'id')
- // No is_active clause: switching an account off means
- // "don't post here", and the job already skips it. Rejecting
- // the payload would stop the user saving any edit, because
- // the editor round-trips the whole destination list.
->where('workspace_id', $this->workspaceId()),
],
'destinations.*.content_type' => [
@@ -118,8 +112,6 @@ public function attributes(): array
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
- // Only once the field rules have run: both sides are then strings
- // that passed `uuid`, instead of whatever the client posted.
if ($validator->errors()->isNotEmpty()) {
return;
}
diff --git a/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php b/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php
index 875f9b735..6bcf41a71 100644
--- a/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php
+++ b/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php
@@ -30,13 +30,11 @@ private function workspaceId(): ?string
return $this->user()->current_workspace_id;
}
- /** Route model binding resolves this, or the request never gets built. */
private function repurpose(): Repurpose
{
return $this->route('repurpose');
}
- /** The format this repurpose will watch once the request is applied. */
private function sourceFormat(): SourceFormat
{
return SourceFormat::tryFrom((string) $this->input('source_format'))
@@ -71,10 +69,6 @@ public function rules(): array
'string',
'uuid',
Rule::exists('social_accounts', 'id')
- // No is_active clause: switching an account off means
- // "don't post here", and the job already skips it. Rejecting
- // the payload would stop the user saving any edit, because
- // the editor round-trips the whole destination list.
->where('workspace_id', $this->workspaceId()),
],
'destinations.*.content_type' => [
@@ -118,8 +112,6 @@ public function attributes(): array
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
- // Only once the field rules have run: both sides are then strings
- // that passed `uuid`, instead of whatever the client posted.
if ($validator->errors()->isNotEmpty()) {
return;
}
diff --git a/app/Http/Resources/Api/RepurposeItemResource.php b/app/Http/Resources/Api/RepurposeItemResource.php
index e8c14fdb1..ec4bd3938 100644
--- a/app/Http/Resources/Api/RepurposeItemResource.php
+++ b/app/Http/Resources/Api/RepurposeItemResource.php
@@ -25,9 +25,6 @@ public function toArray(Request $request): array
'error' => $this->error,
'posts' => $this->whenLoaded('posts', fn () => $this->posts->map(fn ($post) => [
'id' => $post->id,
- // The post carries the truth about publication, not the item:
- // rolling the item status up from here would rewrite history
- // whenever someone edits or deletes a replicated post.
'platforms' => $post->postPlatforms
->where('enabled', true)
->map(fn ($postPlatform) => [
diff --git a/app/Jobs/Repurpose/PollRepurposeSource.php b/app/Jobs/Repurpose/PollRepurposeSource.php
index fe7b1b8d1..9db2b4955 100644
--- a/app/Jobs/Repurpose/PollRepurposeSource.php
+++ b/app/Jobs/Repurpose/PollRepurposeSource.php
@@ -55,9 +55,6 @@ public function handle(SourceFetcherFactory $fetchers): void
}
if ($this->account->disconnected_at !== null || $this->account->is_active === false) {
- // The observer is pausing these. Keep last_error — it is what tells
- // the user why replication stopped — but still move the schedule, or
- // the scheduler re-dispatches this on every tick.
$this->reschedule($repurposes, $this->interval());
return;
@@ -199,9 +196,6 @@ private function recordFailure(Collection $repurposes, Throwable $exception): vo
}
/**
- * Moves the schedule and nothing else. markPolled() additionally clears
- * last_error, which is only right after a poll that actually succeeded.
- *
* @param Collection $repurposes
*/
private function reschedule(Collection $repurposes, int $minutes): void
diff --git a/app/Jobs/Repurpose/ProcessRepurposeItem.php b/app/Jobs/Repurpose/ProcessRepurposeItem.php
index f9df55c63..6ee58e832 100644
--- a/app/Jobs/Repurpose/ProcessRepurposeItem.php
+++ b/app/Jobs/Repurpose/ProcessRepurposeItem.php
@@ -119,8 +119,6 @@ public function handle(MediaAttacher $media, CaptionAdapter $captions): void
}
if ($posts === []) {
- // Not PostCreationFailed: nothing was attempted. Every destination
- // resolved to an account that is gone or switched off.
$this->item->update(['status' => ItemStatus::Failed, 'reason' => ItemReason::NoUsableDestinations]);
return;
@@ -141,16 +139,6 @@ public function handle(MediaAttacher $media, CaptionAdapter $captions): void
$this->item->update(['status' => ItemStatus::Published, 'reason' => null, 'error' => null]);
}
- /**
- * An attempt that died after creating some of its posts leaves them behind
- * as drafts. Every retry clears them on the way in, but the last one has no
- * successor — so without this they sit in the calendar with nothing
- * explaining where they came from.
- *
- * In draft mode the draft is the deliverable, not a leftover: the user can
- * already see and publish it, so a late failure keeps it and the item says
- * what actually happened.
- */
public function failed(Throwable $exception): void
{
$drafts = $this->item->posts()->where('status', PostStatus::Draft)->get();
@@ -170,14 +158,6 @@ public function failed(Throwable $exception): void
]);
}
- /**
- * The item's error is read by humans in the app and served through the
- * public API and MCP, so it must not carry a credential. A CDN download URL
- * is one: Meta signs it with expiring oh/oe parameters, and an HTTP client
- * puts the whole URL in its message. The job knows exactly which string that
- * is, so it is replaced rather than pattern-matched, and TokenRedactor still
- * covers the OAuth shapes it already knows.
- */
private function safeError(Throwable $exception): string
{
$message = str_replace($this->downloadUrl, '[source url]', $exception->getMessage());
@@ -186,10 +166,6 @@ private function safeError(Throwable $exception): string
}
/**
- * Throws so the job's own retries get a chance at it: a source video that is
- * not downloadable right now usually is minutes later. {@see self::failed()}
- * turns the exhausted attempt into the stored reason.
- *
* @param array $posts
*/
private function failDownload(array $posts): never
diff --git a/app/Mcp/Requests/Repurpose/CreateRepurposeRequest.php b/app/Mcp/Requests/Repurpose/CreateRepurposeRequest.php
index 61c8d2642..78b8619d1 100644
--- a/app/Mcp/Requests/Repurpose/CreateRepurposeRequest.php
+++ b/app/Mcp/Requests/Repurpose/CreateRepurposeRequest.php
@@ -48,10 +48,6 @@ public static function rules(?string $workspaceId = null, array $payload = []):
'required',
'string',
'uuid',
- // No is_active clause, matching the web and API requests: a
- // switched-off destination stays on the repurpose and is skipped
- // at publish time, so rejecting it here would stop an agent
- // round-tripping the destination list it was just given.
Rule::exists('social_accounts', 'id')
->where('workspace_id', $workspaceId),
],
diff --git a/app/Mcp/Requests/Repurpose/UpdateRepurposeRequest.php b/app/Mcp/Requests/Repurpose/UpdateRepurposeRequest.php
index 0c9ca7b92..c3a200a9a 100644
--- a/app/Mcp/Requests/Repurpose/UpdateRepurposeRequest.php
+++ b/app/Mcp/Requests/Repurpose/UpdateRepurposeRequest.php
@@ -52,10 +52,6 @@ public static function rules(?string $workspaceId = null, ?Repurpose $repurpose
'required',
'string',
'uuid',
- // No is_active clause, matching the web and API requests: a
- // switched-off destination stays on the repurpose and is skipped
- // at publish time, so rejecting it here would stop an agent
- // round-tripping the destination list it was just given.
Rule::exists('social_accounts', 'id')
->where('workspace_id', $workspaceId),
],
diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php
index aea2def59..9808eee55 100644
--- a/app/Mcp/Servers/TryPostServer.php
+++ b/app/Mcp/Servers/TryPostServer.php
@@ -109,7 +109,6 @@ class TryPostServer extends Server
ListDiscordChannelsTool::class,
ToggleSocialAccountTool::class,
- // Repurpose
ListRepurposeTemplatesTool::class,
ListRepurposesTool::class,
CreateRepurposeTool::class,
diff --git a/app/Models/Repurpose.php b/app/Models/Repurpose.php
index b2dad7237..49ce0b5ba 100644
--- a/app/Models/Repurpose.php
+++ b/app/Models/Repurpose.php
@@ -8,7 +8,6 @@
use App\Enums\Repurpose\PublishMode;
use App\Enums\Repurpose\SourceFormat;
use App\Enums\Repurpose\Status;
-use Database\Factories\RepurposeFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -17,7 +16,6 @@
class Repurpose extends Model
{
- /** @use HasFactory */
use HasFactory, HasUuids;
protected $fillable = [
diff --git a/app/Models/RepurposeItem.php b/app/Models/RepurposeItem.php
index 5b0615c1e..171f32815 100644
--- a/app/Models/RepurposeItem.php
+++ b/app/Models/RepurposeItem.php
@@ -6,7 +6,6 @@
use App\Enums\Repurpose\ItemReason;
use App\Enums\Repurpose\ItemStatus;
-use Database\Factories\RepurposeItemFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -15,7 +14,6 @@
class RepurposeItem extends Model
{
- /** @use HasFactory */
use HasFactory, HasUuids;
protected $fillable = [
diff --git a/app/Observers/SocialAccountObserver.php b/app/Observers/SocialAccountObserver.php
index 8745c1fd2..e51653d39 100644
--- a/app/Observers/SocialAccountObserver.php
+++ b/app/Observers/SocialAccountObserver.php
@@ -51,8 +51,6 @@ public function deleting(SocialAccount $socialAccount): void
public function updated(SocialAccount $socialAccount): void
{
- // Its own guard, ahead of the status-only early return below: widening
- // that one would change the PostHog and onboarding behaviour behind it.
if ($socialAccount->wasChanged(['status', 'is_active', 'platform'])) {
app(RepurposeAccountSync::class)->accountChanged($socialAccount);
}
diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php
index 8dc232c6c..107871649 100644
--- a/app/Policies/WorkspacePolicy.php
+++ b/app/Policies/WorkspacePolicy.php
@@ -61,10 +61,6 @@ public function manageWebhooks(User $user, Workspace $workspace): bool
return $this->isOwnerOrWorkspaceAdmin($user, $workspace);
}
- /**
- * Repurpose creates posts on the workspace's behalf, so it follows the
- * post-creation role rather than the stricter integration roles.
- */
public function manageRepurposes(User $user, Workspace $workspace): bool
{
return $this->createPost($user, $workspace);
diff --git a/app/Rules/Repurpose/SourceIsFree.php b/app/Rules/Repurpose/SourceIsFree.php
index 8c7a577cf..ce5d189a2 100644
--- a/app/Rules/Repurpose/SourceIsFree.php
+++ b/app/Rules/Repurpose/SourceIsFree.php
@@ -10,11 +10,6 @@
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Str;
-/**
- * One workspace watches a given account for a given format once. The database
- * says the same thing, and says it last — this exists so the user reads a
- * sentence instead of the constraint.
- */
class SourceIsFree implements ValidationRule
{
public function __construct(
diff --git a/app/Services/Repurpose/CaptionAdapter.php b/app/Services/Repurpose/CaptionAdapter.php
index 2f2d83c04..29051093e 100644
--- a/app/Services/Repurpose/CaptionAdapter.php
+++ b/app/Services/Repurpose/CaptionAdapter.php
@@ -29,11 +29,6 @@ public function adapt(Workspace $workspace, ?User $user, string $caption, Platfo
?? $this->truncate($caption, $platform);
}
- /**
- * What the publisher actually puts on the network. Sanitizing moves the length
- * both ways — HTML comes off, X rewrites every dot of a host — so the raw
- * caption is never the thing to measure a limit against.
- */
private function sent(string $caption, Platform $platform): string
{
return $this->sanitizer->displayText($caption, $platform);
@@ -44,10 +39,6 @@ private function fits(string $caption, Platform $platform): bool
return $platform->contentOverflow($this->sent($caption, $platform)) === 0;
}
- /**
- * Null whenever the workspace cannot buy a rewrite or the model does not
- * deliver one that fits, which sends the caller to plain truncation.
- */
private function shorten(Workspace $workspace, ?User $user, string $caption, Platform $platform): ?string
{
if ($user === null || Gate::forUser($user)->denies('useAi', $workspace->account)) {
@@ -94,11 +85,6 @@ private function truncate(string $caption, Platform $platform): string
return $caption;
}
- /**
- * How many raw characters to try next: the current length scaled by how much
- * the sent text has to shrink, and always at least one shorter so the cut
- * cannot stall on a caption that sanitizes to something longer.
- */
private function fittingLength(string $caption, Platform $platform): int
{
$length = mb_strlen($caption);
diff --git a/app/Services/Repurpose/FacebookSourceFetcher.php b/app/Services/Repurpose/FacebookSourceFetcher.php
index 376d7536f..63f4a4e05 100644
--- a/app/Services/Repurpose/FacebookSourceFetcher.php
+++ b/app/Services/Repurpose/FacebookSourceFetcher.php
@@ -15,15 +15,8 @@ class FacebookSourceFetcher extends MetaSourceFetcher
{
private const VIDEO_FIELDS = 'id,source,description,permalink_url,created_time';
- /**
- * Not a quota lever: a page of rows costs the same single call whatever its
- * size. It bounds how far back a poll can catch up after an outage, and on
- * the stories edge — where resolving each row's file costs its own call —
- * how many of those a single poll can fire.
- */
private const PAGE_SIZE = 25;
- /** The Video node's documented readable fields, without the permalink. */
private const PUBLIC_VIDEO_FIELDS = 'id,source,description,created_time';
/**
@@ -35,10 +28,6 @@ public function fetch(SocialAccount $account, ?CarbonInterface $since, array $fo
$wantsReels = in_array(SourceFormat::Reel, $formats, true);
$wantsVideos = in_array(SourceFormat::Video, $formats, true);
- // The reels edge is read whenever videos are wanted, even if reels are
- // not: /videos lists reels too and carries nothing to tell them apart,
- // so this is the only way to subtract them. One extra call per poll,
- // and only for a page watched for feed videos.
$reels = $wantsReels || $wantsVideos
? $this->videos($account, 'video_reels', $since, SourceFormat::Reel)
: [];
diff --git a/app/Services/Repurpose/InstagramSourceFetcher.php b/app/Services/Repurpose/InstagramSourceFetcher.php
index b6c01809e..a5d07b70b 100644
--- a/app/Services/Repurpose/InstagramSourceFetcher.php
+++ b/app/Services/Repurpose/InstagramSourceFetcher.php
@@ -16,13 +16,8 @@ class InstagramSourceFetcher extends MetaSourceFetcher
{
private const FIELDS = 'id,media_type,media_product_type,media_url,caption,permalink,timestamp';
- /** Everything Meta marks as public, so any Instagram token can read it. */
private const PUBLIC_FIELDS = 'id,media_type,media_url,permalink,timestamp';
- /**
- * Not a quota lever: a page of rows costs the same single call whatever its
- * size. It bounds how far back a poll can catch up after an outage.
- */
private const PAGE_SIZE = 25;
/**
@@ -79,12 +74,6 @@ private function toSourceMedia(array $row, ?SourceFormat $edgeFormat): SourceMed
}
/**
- * Meta documents media_product_type as available to the Facebook-login API
- * only, and our standalone Instagram accounts talk to graph.instagram.com.
- * The surface is therefore taken from the edge that returned the row where
- * it is unambiguous, and a video off /media with no product type is read as
- * a Reel, which is what Instagram serves new feed video as.
- *
* @param array $row
*/
private function format(array $row, ?SourceFormat $edgeFormat): ?SourceFormat
diff --git a/app/Services/Repurpose/MetaSourceFetcher.php b/app/Services/Repurpose/MetaSourceFetcher.php
index 5d7e2d458..665a95790 100644
--- a/app/Services/Repurpose/MetaSourceFetcher.php
+++ b/app/Services/Repurpose/MetaSourceFetcher.php
@@ -11,23 +11,12 @@
abstract class MetaSourceFetcher implements SourceFetcher
{
- /**
- * Facebook resolves the file behind each story with its own request, so a
- * page of stories costs one call per item. The timeout is what keeps that
- * worst case inside the queue's, since a poll that outlives the worker is
- * dispatched again on the next tick and never gets to record its result.
- */
protected function http(SocialAccount $account): PendingRequest
{
return Http::timeout(15)->withToken($account->access_token);
}
/**
- * Meta marks some fields as available to one login type only, and asking for
- * one the token cannot have fails the whole read rather than omitting it.
- * The reduced set is what every token can read, so the source keeps working
- * with less detail instead of going dark.
- *
* @param array $query
* @return array>
*/
diff --git a/app/Services/Repurpose/RepurposeAccountSync.php b/app/Services/Repurpose/RepurposeAccountSync.php
index dabe80439..daa6aeb83 100644
--- a/app/Services/Repurpose/RepurposeAccountSync.php
+++ b/app/Services/Repurpose/RepurposeAccountSync.php
@@ -19,21 +19,8 @@
use Illuminate\Validation\ValidationException;
use Throwable;
-/**
- * Keeps repurposes honest about the social accounts they depend on.
- *
- * Source and destination are handled asymmetrically on purpose. A repurpose
- * cannot run without a working source, so any source failure stops it. A
- * destination is different: a disconnected one keeps flowing to the publisher,
- * which fails the post visibly and lets the user retry it after reconnecting.
- */
class RepurposeAccountSync
{
- /**
- * Called from the observer's `deleting` hook rather than `deleted`: the
- * source FK is nullOnDelete, so by the time `deleted` fires the link is
- * already gone and the affected repurposes can no longer be found.
- */
public function accountRemoved(SocialAccount $account): void
{
$this->guard(function () use ($account): void {
@@ -64,14 +51,6 @@ public function accountChanged(SocialAccount $account): void
}, $account);
}
- /**
- * Read from the database, not from the instance. The observer receives
- * whatever model the caller happened to be holding, and a column it never
- * loaded — is_active is not in SocialAccountFactory, so a freshly created
- * account has no such attribute in memory — reads back as null rather than
- * throwing, because strict mode exempts recently-created models. That turns
- * a healthy account into a false negative and silently skips auto-resume.
- */
private function isUsable(SocialAccount $account): bool
{
return SocialAccount::query()
@@ -82,12 +61,6 @@ private function isUsable(SocialAccount $account): bool
}
/**
- * Active only, so the transition is not handed rows it would reject anyway.
- * The guarantee itself lives in pause()'s `from` list: a repurpose the user
- * paused deliberately must not acquire a system reason, or auto-resume would
- * turn back on something they turned off. Both hold; only this one is an
- * optimisation.
- *
* @return Collection
*/
private function sourcedBy(SocialAccount $account): Collection
@@ -98,17 +71,6 @@ private function sourcedBy(SocialAccount $account): Collection
->get();
}
- /**
- * Only SourceUnavailable can auto-resume. SourceRemoved and NoDestinations
- * describe state no account event restores — a reconnection after a delete
- * is a new row, and a pruned destination is gone from the JSON — so both
- * wait for the user.
- *
- * Eligibility is checked before calling Resume, not discovered from its
- * exception: it throws on both a wrong status and a failed health gate, and
- * driving normal control flow through exceptions inside an observer would
- * fill the log with expected failures on every verification sweep.
- */
private function resumeRecovered(SocialAccount $account): void
{
$candidates = Repurpose::query()
@@ -129,11 +91,6 @@ private function resumeRecovered(SocialAccount $account): void
}
}
- /**
- * The id can never resolve again, so it comes out of the stored list. A
- * deactivated account is never pruned: that is recoverable, and pruning
- * would lose the destination for good when it is switched back on.
- */
private function pruneDestination(SocialAccount $account): void
{
foreach ($this->destinedFor($account) as $repurpose) {
@@ -150,13 +107,6 @@ private function pruneDestination(SocialAccount $account): void
}
}
- /**
- * Reconnecting through the other variant of a network moves the row's
- * platform, and the stored content type may not exist there —
- * ContentType::forPlatform() shares nothing between LinkedIn and LinkedIn
- * Page. SocialAccount::realignUnpublishedTargets() already does this repair
- * for pending post targets; the repurpose's JSON was never included.
- */
private function realignDestinations(SocialAccount $account): void
{
$supported = array_map(
@@ -184,11 +134,6 @@ private function realignDestinations(SocialAccount $account): void
}
/**
- * Filtered in PHP on purpose: `destinations` holds objects, and
- * partial-object containment needs a different candidate shape on
- * PostgreSQL (`@>` wants it wrapped in an array) than on MySQL. The row
- * count is bounded by connected accounts times source formats.
- *
* @return SupportCollection
*/
private function destinedFor(SocialAccount $account): SupportCollection
@@ -213,12 +158,6 @@ private function pause(Repurpose $repurpose, PauseReason $reason): void
);
}
- /**
- * Nothing here may break the account operation that triggered it. The
- * delete hook runs inside `$account->delete()`, so an exception aborts a
- * disconnect with a 500; and `SocialAccount::persistIdentity()` wraps a
- * reconnect in a transaction, so an exception would roll the reconnect back.
- */
private function guard(callable $work, SocialAccount $account): void
{
try {
diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php
index d1f00ff6a..a12b60a36 100644
--- a/app/Services/Social/YouTubePublisher.php
+++ b/app/Services/Social/YouTubePublisher.php
@@ -205,11 +205,6 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount
}
}
- /**
- * YouTube counts a title in characters, so the cut has to as well: measuring
- * bytes trims accented copy earlier than it needs to and can slice a
- * multi-byte character in half, which is what reaches the API.
- */
private function buildTitle(string $content): string
{
$maxLength = 100;
diff --git a/app/Support/Repurpose/DestinationMetaRules.php b/app/Support/Repurpose/DestinationMetaRules.php
index b7add1dd4..2e4269853 100644
--- a/app/Support/Repurpose/DestinationMetaRules.php
+++ b/app/Support/Repurpose/DestinationMetaRules.php
@@ -7,12 +7,6 @@
use App\Support\PostPlatformMetaRules;
use Illuminate\Support\Str;
-/**
- * Re-keys the shared per-platform meta rules from `platforms.*` to the
- * `destinations.*` a repurpose submits them under. The rules themselves stay in
- * {@see PostPlatformMetaRules}: validated() strips any key without a rule, so a
- * meta field spelled out here instead would be dropped by every other surface.
- */
class DestinationMetaRules
{
/**
diff --git a/app/Support/Repurpose/RepurposeTransition.php b/app/Support/Repurpose/RepurposeTransition.php
index 98055a2c6..ff87eb6e4 100644
--- a/app/Support/Repurpose/RepurposeTransition.php
+++ b/app/Support/Repurpose/RepurposeTransition.php
@@ -12,11 +12,6 @@
class RepurposeTransition
{
/**
- * Every lifecycle change reads the status and writes it back, so it holds
- * the row across both halves and re-reads it inside: the caller's copy was
- * loaded before the request, and two callers arriving together would each
- * pass the check the other is about to invalidate.
- *
* @param array $from
* @param callable(Repurpose): void $change
*/
@@ -36,12 +31,6 @@ public static function apply(Repurpose $repurpose, array $from, string $message,
}
/**
- * The system's transition. A status that moved on since the caller read it
- * is an outcome, not an error — two accounts of one repurpose dying in the
- * same sweep would otherwise throw out of an observer and take the sweep
- * with it. Returning null also makes the pause idempotent, so a repurpose
- * that is already stopped is left exactly as it was.
- *
* @param array $from
* @param callable(Repurpose): void $change
*/
diff --git a/app/Support/Repurpose/SourceIsNotADestination.php b/app/Support/Repurpose/SourceIsNotADestination.php
index b9388be36..8fc9b5498 100644
--- a/app/Support/Repurpose/SourceIsNotADestination.php
+++ b/app/Support/Repurpose/SourceIsNotADestination.php
@@ -7,21 +7,9 @@
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Validator;
-/**
- * A repurpose copies what an account posts elsewhere. Pointing it back at that
- * same account would republish the video onto the profile it came from.
- *
- * Checked after the field rules rather than as one of them: the source id it
- * compares against is a *different* field, and a rule object is built while
- * rules() is assembled — before anything has been validated, when the payload
- * is still whatever the client sent. Running here means both sides are already
- * strings that passed `uuid`.
- */
class SourceIsNotADestination
{
/**
- * For request-driven flows, from withValidator().
- *
* @param array $destinations
*/
public static function addErrors(Validator $validator, array $destinations, ?string $sourceAccountId): void
@@ -32,8 +20,6 @@ public static function addErrors(Validator $validator, array $destinations, ?str
}
/**
- * For MCP, which validates in one call and has no validator to add to.
- *
* @param array $destinations
*/
public static function assert(array $destinations, ?string $sourceAccountId): void
diff --git a/resources/js/components/posts/editor/DiscordSettings.vue b/resources/js/components/posts/editor/DiscordSettings.vue
index 9d3a95154..05c1598dd 100644
--- a/resources/js/components/posts/editor/DiscordSettings.vue
+++ b/resources/js/components/posts/editor/DiscordSettings.vue
@@ -45,11 +45,6 @@ interface EmbedDraft {
color?: string;
}
-/**
- * Discord's embed colour is a plain six-digit hex with no alpha, which is also
- * what the API accepts, so a shorthand or an alpha value from the picker is
- * brought back to that shape rather than rejected on save.
- */
const DISCORD_BLURPLE = '#5865f2';
const props = withDefaults(
@@ -67,7 +62,6 @@ const open = ref(false);
const updateMeta = (patch: Record) => emit('update:meta', { ...props.meta, ...patch });
-// --- Channel picker (live fetch) ---------------------------------------------
const channels = ref([]);
const channelsLoading = ref(false);
const channelsHttp = useHttp, { channels: DiscordChannel[] }>();
@@ -96,8 +90,6 @@ const channelId = computed({
set: (value: string) => updateMeta({ channel_id: value || null }),
});
-// Keep a saved channel selectable even before the live list loads (or if the
-// lookup is unavailable), so editing a post never visually "loses" its channel.
const channelOptions = computed(() => {
if (channelId.value && !channels.value.some((channel) => channel.id === channelId.value)) {
return [{ id: channelId.value, name: channelId.value }, ...channels.value];
@@ -108,8 +100,6 @@ const channelOptions = computed(() => {
const channelSelectOptions = computed(() => channelOptions.value.map((channel) => ({ value: channel.id, label: `#${channel.name}` })));
-// Persist the channel NAME alongside the id (display-only, for the preview) and
-// keep it fresh as the live list loads or the channel is renamed.
watch([channelId, channels], () => {
const name = channels.value.find((channel) => channel.id === channelId.value)?.name;
@@ -127,7 +117,6 @@ const channelError = computed(() => {
return Object.entries(errors.value).find(([key]) => key.endsWith('.meta.channel_id'))?.[1];
});
-// --- Mentions (autocomplete chips) -------------------------------------------
const mentionQuery = ref('');
const mentionResults = ref([]);
const mentionsHttp = useHttp, { mentions: MentionTarget[] }>();
@@ -178,10 +167,6 @@ const addMention = (target: MentionTarget) => {
const removeMention = (token: string) =>
updateMeta({ mentions: mentions.value.filter((mention) => mention.token !== token) });
-// --- Embeds (repeater) -------------------------------------------------------
-// Derived straight from meta (like channel/mentions) so it never diverges from
-// the persisted/auto-saved state. Inputs are controlled (one-way :value + emit),
-// so index keys are safe — Vue patches each reused row to the correct values.
const embeds = computed(() => (Array.isArray(props.meta?.embeds) ? (props.meta!.embeds as EmbedDraft[]) : []));
const addEmbed = () => updateMeta({ embeds: [...embeds.value, {}] });
@@ -221,7 +206,6 @@ const updateEmbed = (index: number, patch: Partial) =>
diff --git a/resources/js/components/repurpose/RepurposeFlow.vue b/resources/js/components/repurpose/RepurposeFlow.vue
index 7a8964423..64ae5b78f 100644
--- a/resources/js/components/repurpose/RepurposeFlow.vue
+++ b/resources/js/components/repurpose/RepurposeFlow.vue
@@ -21,9 +21,6 @@ withDefaults(
-
();
-/**
- * Derived from current account health, never from paused_reason. The stored
- * reason decides the watermark and whether the system may resume on its own; it
- * is not a description of the situation the user is looking at now, which may
- * already be fixed.
- */
const state = computed(() => {
if (props.repurpose.status !== RepurposeStatus.Paused || props.repurpose.paused_reason === null) {
return null;
diff --git a/resources/js/components/repurpose/RepurposeItemList.vue b/resources/js/components/repurpose/RepurposeItemList.vue
index cdb57ced5..080c171ec 100644
--- a/resources/js/components/repurpose/RepurposeItemList.vue
+++ b/resources/js/components/repurpose/RepurposeItemList.vue
@@ -33,11 +33,6 @@ const marks: Record item.error ?? null;
-/**
- * The post's own state, not the item's. A failure on any network is the thing
- * worth surfacing; otherwise a single shared state only reads as settled when
- * every network agrees on it.
- */
const postState = (post: RepurposeItemPost): PostPlatformStatusValue | null => {
const states = post.platforms
.map((entry) => entry.status)
diff --git a/resources/js/components/repurpose/SourceFormatCard.vue b/resources/js/components/repurpose/SourceFormatCard.vue
index 554496692..6e914ff4b 100644
--- a/resources/js/components/repurpose/SourceFormatCard.vue
+++ b/resources/js/components/repurpose/SourceFormatCard.vue
@@ -21,15 +21,9 @@ const props = defineProps<{
error?: string;
}>();
-// Nullable: a repurpose whose source account was deleted arrives here with none,
-// and this card is where the user picks a replacement.
const account = defineModel('account', { required: true });
const format = defineModel('format', { required: true });
-/**
- * SearchableSelect speaks string | undefined; the repurpose stores null when its
- * source account was deleted. Bridge the two here rather than widening either.
- */
const selectedAccount = computed({
get: () => account.value ?? undefined,
set: (value: string | undefined) => {
diff --git a/resources/js/pages/repurposes/Index.vue b/resources/js/pages/repurposes/Index.vue
index e29b14e7a..1fcfbe82d 100644
--- a/resources/js/pages/repurposes/Index.vue
+++ b/resources/js/pages/repurposes/Index.vue
@@ -141,8 +141,6 @@ const handleDelete = (repurpose: Repurpose) => {
{{ $t(`repurposes.status.${repurpose.status}`) }}
-
account.id));
const form = useForm<{
- // Nullable: a repurpose whose source account was deleted keeps its history
- // and waits here for the user to pick a new one.
source_social_account_id: string | null;
source_format: RepurposeSourceFormat;
publish_mode: RepurposePublishMode;
@@ -71,16 +69,10 @@ const form = useForm<{
const errors = usePageErrors();
-/**
- * The destinations are configured before there is anything to publish, so the
- * media rules would read "no files" and warn about every video-only format. A
- * repurpose always attaches exactly one video, which is what they get to judge.
- */
const plannedMedia = computed(() => [
{ id: 'repurpose-video', url: '', type: MediaType.Video },
]);
-/** Whatever the source becomes cannot also receive, so it leaves the list. */
const destinationAccounts = computed(() =>
props.destinationAccounts.filter((account) => account.id !== form.source_social_account_id),
);
@@ -119,12 +111,6 @@ const channels = computed(() =>
const selectedAccountIds = computed(() => form.destinations.map((destination) => destination.social_account_id));
-/**
- * Selected destinations whose account is switched off. They stay on the
- * repurpose and are skipped at publish time, so the only thing missing is
- * saying so — otherwise the video quietly reaches fewer networks than the
- * configuration claims.
- */
const pausedDestinations = computed(() =>
props.destinationAccounts
.filter((account) => account.is_active === false && selectedAccountIds.value.includes(account.id))
@@ -160,7 +146,6 @@ const setDestinationContentType = (accountId: string, contentType: string) =>
const setDestinationMeta = (accountId: string, meta: Record) =>
updateDestination(accountId, { meta });
-/** The account the header describes is the one being chosen, not the saved one. */
const selectedSourceAccount = computed(
() =>
props.sourceAccounts.find((account) => account.id === form.source_social_account_id)
@@ -329,7 +314,6 @@ const handleDelete = () => {
-
diff --git a/resources/js/types/channel.ts b/resources/js/types/channel.ts
index ffc9fdb8c..2ddcda83a 100644
--- a/resources/js/types/channel.ts
+++ b/resources/js/types/channel.ts
@@ -7,7 +7,6 @@ export interface ChannelAccount {
username: string;
display_label: string;
avatar_url: string | null;
- /** Both come from SocialAccountResource; optional because not every caller selects them. */
is_active?: boolean;
status?: string;
}
@@ -23,11 +22,6 @@ export interface ChannelTikTokCreatorInfo {
max_video_post_duration_sec: number | null;
}
-/**
- * One selectable publishing channel for the post editor's channels tab. `id`
- * is the selection/update key (a post_platform id); `socialAccount` is what
- * the per-platform Settings components consume.
- */
export interface Channel {
id: string;
platform: string;
diff --git a/resources/js/types/repurpose-status.ts b/resources/js/types/repurpose-status.ts
index eec4a2970..08f30690e 100644
--- a/resources/js/types/repurpose-status.ts
+++ b/resources/js/types/repurpose-status.ts
@@ -7,11 +7,6 @@ export const RepurposeStatus = {
export type RepurposeStatusValue = (typeof RepurposeStatus)[keyof typeof RepurposeStatus];
-/**
- * Why the system stopped a repurpose. NULL means the user paused it, which is
- * what decides whether Resume replays the backlog. It governs the watermark and
- * auto-resume eligibility only — the banner reads current account health.
- */
export const PauseReason = {
SourceRemoved: 'source_removed',
SourceUnavailable: 'source_unavailable',
@@ -20,12 +15,6 @@ export const PauseReason = {
export type PauseReasonValue = (typeof PauseReason)[keyof typeof PauseReason];
-/**
- * What the page tells the user about a stopped repurpose. Derived from current
- * account health, so it is not PauseReason: that one records why the system
- * stopped and drives the watermark, while this describes the situation now,
- * which may already be fixed. Each value is also its `repurposes.health.*` key.
- */
export const RepurposeHealth = {
SourceMissing: 'source_missing',
SourceUnusable: 'source_unusable',
diff --git a/tests/Browser/RepurposeAccountHealthTest.php b/tests/Browser/RepurposeAccountHealthTest.php
index 0fdd49a45..3e2fee22c 100644
--- a/tests/Browser/RepurposeAccountHealthTest.php
+++ b/tests/Browser/RepurposeAccountHealthTest.php
@@ -11,10 +11,6 @@
use App\Models\User;
use App\Models\Workspace;
-/**
- * Wait for a data-testid element to mount and lay out. Pest browser `@`
- * selectors resolve to data-testid, and assertions do not auto-wait on SPA paint.
- */
function waitForRepurposeHealthTestId(mixed $page, string $testId): void
{
$page->script(<<for($workspace)->create(['platform' => Platform::TikTok]);
- // The state the observer leaves behind when the watched account is deleted:
- // the repurpose and its history survive, with no source to point at.
$repurpose = Repurpose::factory()->create([
'workspace_id' => $workspace->id,
'source_social_account_id' => null,
@@ -62,8 +56,6 @@ function waitForRepurposeHealthTestId(mixed $page, string $testId): void
$page->assertSee(__('repurposes.health.source_missing'))
->assertSee(__('repurposes.summary.no_source'))
- // getPlatformLogo falls back to LinkedIn for an unknown platform, so
- // without its own state the flow would claim this watches LinkedIn.
->assertPresent('@flow-source-missing')
->assertNoJavaScriptErrors();
});
diff --git a/tests/Feature/Api/RepurposeApiTest.php b/tests/Feature/Api/RepurposeApiTest.php
index a8722e39f..46392395b 100644
--- a/tests/Feature/Api/RepurposeApiTest.php
+++ b/tests/Feature/Api/RepurposeApiTest.php
@@ -303,7 +303,6 @@ function tiktokDestinationPayload(SocialAccount $account): array
->assertOk()
->assertJsonPath('paused_reason', PauseReason::SourceUnavailable->value);
- // The health gate lives in the action, so every surface inherits it.
$this->withHeaders(apiHeaders($this->token))
->postJson(route('api.repurposes.resume', $repurpose))
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
diff --git a/tests/Feature/Mcp/RepurposeToolTest.php b/tests/Feature/Mcp/RepurposeToolTest.php
index b7e44ddff..3514c1f54 100644
--- a/tests/Feature/Mcp/RepurposeToolTest.php
+++ b/tests/Feature/Mcp/RepurposeToolTest.php
@@ -298,7 +298,6 @@ function tiktokDestinationForMcp(SocialAccount $account): array
$this->source->update(['status' => AccountStatus::Disconnected]);
- // The gate lives in the action, so the tool inherits it without knowing.
TryPostServer::actingAs($this->user)
->tool(ActivateRepurposeTool::class, ['repurpose_id' => $repurpose->id])
->assertHasErrors();
@@ -324,8 +323,6 @@ function tiktokDestinationForMcp(SocialAccount $account): array
'status' => PostPlatformStatus::Published,
]);
- // Shares ListRepurposeItems with the web and the API, so the eager load can
- // no longer drift out of step with what the resource reads.
TryPostServer::actingAs($this->user)
->tool(ListRepurposeItemsTool::class, ['repurpose_id' => $repurpose->id])
->assertOk()
@@ -341,8 +338,6 @@ function tiktokDestinationForMcp(SocialAccount $account): array
$this->tiktok->update(['is_active' => false]);
- // An agent round-trips the destination list it was given, exactly like the
- // editor does, so rejecting a paused destination would block every update.
TryPostServer::actingAs($this->user)
->tool(UpdateRepurposeTool::class, [
'repurpose_id' => $repurpose->id,
diff --git a/tests/Feature/Repurpose/AccountHealthTest.php b/tests/Feature/Repurpose/AccountHealthTest.php
index 4c79cf008..8ac3b1af7 100644
--- a/tests/Feature/Repurpose/AccountHealthTest.php
+++ b/tests/Feature/Repurpose/AccountHealthTest.php
@@ -26,13 +26,6 @@
use Illuminate\Validation\ValidationException;
/**
- * File-local on purpose. Pest helpers are global functions that only exist once
- * their defining file has loaded, and this file is run on its own, so it cannot
- * borrow ActionsTest.php's. The names are unique for the same reason.
- *
- * For Action and service tests only: these do not set current_workspace_id,
- * which RepurposePolicy requires, so HTTP tests build their own fixtures.
- *
* @return array{0: Workspace, 1: User, 2: SocialAccount}
*/
function healthWorkspace(): array
@@ -455,9 +448,6 @@ function healthDestination(Workspace $workspace): array
});
test('disconnecting an account says how many automations it paused', function () {
- // Full HTTP setup, not healthWorkspace(): this route authorises
- // manageAccounts on the current workspace, so the workspace needs the
- // user's account_id and the user needs current_workspace_id.
$user = User::factory()->create();
$workspace = Workspace::factory()->create([
'account_id' => $user->account_id,
@@ -558,8 +548,6 @@ function healthDestination(Workspace $workspace): array
],
]);
- // The account being disconnected is a destination, not the source — the
- // repurpose still stops, so the flash has to say so.
$this->actingAs($user)
->delete(route('app.accounts.disconnect', $only))
->assertSessionHas('flash.banner', trans_choice('accounts.flash.disconnected_paused_repurposes', 1, ['count' => 1]));
@@ -585,8 +573,6 @@ function healthDestination(Workspace $workspace): array
});
test('a supported content type survives a platform change untouched', function () {
- // healthWorkspace() already seats an Instagram as the source, and the
- // one-account-per-network rule would refuse a second one.
config()->set('trypost.allow_multiple_social_accounts', true);
[$workspace, $user, $source] = healthWorkspace();
@@ -601,8 +587,6 @@ function healthDestination(Workspace $workspace): array
],
]);
- // Instagram and Instagram-via-Facebook share their content types, so the
- // stored one is still valid and must not be rewritten to the default.
$instagram->update(['platform' => Platform::InstagramFacebook]);
expect(data_get($repurpose->fresh()->destinations, '0.content_type'))
@@ -623,8 +607,6 @@ function healthDestination(Workspace $workspace): array
$item = RepurposeItem::factory()->for($repurpose)->create();
- // Straight to the job with the destination still stored but the account
- // gone: the pruning path and the job's own guard are separate defences.
$repurpose->update(['destinations' => $repurpose->destinations]);
$gone->forceDelete();
@@ -645,21 +627,17 @@ function healthDestination(Workspace $workspace): array
'destinations' => [$destination],
]);
- // The account is deleted: the repurpose survives, orphaned and paused.
$source->delete();
expect($repurpose->fresh()->paused_reason)->toBe(PauseReason::SourceRemoved)
->and($repurpose->fresh()->source_social_account_id)->toBeNull();
- // Reconnecting is a brand new row, so the user picks it as the source.
$replacement = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]);
UpdateRepurpose::execute($repurpose, ['source_social_account_id' => $replacement->id]);
$resumed = ResumeRepurpose::execute($repurpose->fresh());
- // A thirty-day back catalogue on an account this repurpose never watched
- // must not be replicated the moment it is pointed at.
expect($resumed->status)->toBe(Status::Active)
->and($resumed->paused_reason)->toBeNull()
->and($resumed->activated_at->isToday())->toBeTrue();
@@ -692,9 +670,6 @@ function healthDestination(Workspace $workspace): array
$account = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]);
- // Malformed destinations make the sync's typed closure throw. The delete
- // hook runs inside $account->delete(), so an exception escaping it would
- // turn disconnecting an account into a 500 because of a side module.
Repurpose::factory()->for($workspace)->create([
'source_social_account_id' => $account->id,
'status' => Status::Active,
@@ -720,8 +695,6 @@ function healthDestination(Workspace $workspace): array
'destinations' => [healthDestination($workspace)],
]);
- // The index badges a non-null reason as "stopped on its own". Carrying it
- // into Disabled would tell the user the system did something they did.
$disabled = DisableRepurpose::execute($repurpose);
expect($disabled->status)->toBe(Status::Disabled)
@@ -755,8 +728,6 @@ function healthDestination(Workspace $workspace): array
'destinations' => [healthDestination($workspace)],
]);
- // The watermark only moves for a repurpose that had one. A draft has never
- // watched anything, so activation is what stamps it.
UpdateRepurpose::execute($repurpose, ['source_social_account_id' => $other->id]);
expect($repurpose->fresh()->activated_at)->toBeNull();
diff --git a/tests/Feature/Repurpose/ActionsTest.php b/tests/Feature/Repurpose/ActionsTest.php
index 5f19bb1cd..5f4b8648d 100644
--- a/tests/Feature/Repurpose/ActionsTest.php
+++ b/tests/Feature/Repurpose/ActionsTest.php
@@ -203,8 +203,6 @@ function tiktokDestination(Workspace $workspace): array
test('pausing keeps the watermark and resuming does not move it', function () {
[$workspace, $user, $account] = repurposeWorkspace();
- // Resuming runs the same health gates as activating, so the repurpose needs
- // a usable source and destination for this watermark test to reach them.
$repurpose = Repurpose::factory()->active()->create([
'workspace_id' => $workspace->id,
'source_social_account_id' => $account->id,
diff --git a/tests/Feature/Repurpose/PollingTest.php b/tests/Feature/Repurpose/PollingTest.php
index b227f4431..57a7c183c 100644
--- a/tests/Feature/Repurpose/PollingTest.php
+++ b/tests/Feature/Repurpose/PollingTest.php
@@ -313,8 +313,6 @@ function poll(SocialAccount $account): void
$fresh = $repurpose->fresh();
- // The error is what tells the user why it stopped, so it survives. The
- // schedule still moves, or the scheduler re-dispatches this on every tick.
expect($fresh->last_error)->toBe('Instagram rejected the request')
->and($fresh->next_poll_at->isFuture())->toBeTrue();
});
@@ -337,8 +335,6 @@ function poll(SocialAccount $account): void
])->id,
]);
- // The observer pauses an orphan, so the command should never see one — but a
- // null id must not reach whereKey() even if something else leaves one Active.
$orphan->update(['source_social_account_id' => null, 'status' => Status::Active]);
Artisan::call('repurpose:poll');
@@ -360,12 +356,6 @@ function poll(SocialAccount $account): void
'activated_at' => now()->subDays(30),
]);
- // The source keeps returning the same page every interval, so only the
- // first sighting is work. Two things enforce that and this pins the
- // outcome, not either one: the wasRecentlyCreated check in logMedia, and
- // ProcessRepurposeItem being ShouldBeUnique on the item id. The second
- // masks the first for an hour, which is why removing the check alone does
- // not fail here — past that window the check is what still holds.
(new PollRepurposeSource($source))->handle(app(SourceFetcherFactory::class));
(new PollRepurposeSource($source))->handle(app(SourceFetcherFactory::class));
diff --git a/tests/Feature/Repurpose/ProcessItemTest.php b/tests/Feature/Repurpose/ProcessItemTest.php
index a89b47b49..1a22437fd 100644
--- a/tests/Feature/Repurpose/ProcessItemTest.php
+++ b/tests/Feature/Repurpose/ProcessItemTest.php
@@ -437,7 +437,6 @@ function processItem(RepurposeItem $item, string $caption = 'My caption'): void
test('an exhausted publish-mode item leaves no orphan drafts behind', function () {
$item = repurposeWithTwoDestinations();
- // What an attempt that died after creating its posts leaves behind.
$post = Post::factory()->create([
'workspace_id' => $item->repurpose->workspace_id,
'repurpose_item_id' => $item->id,
@@ -461,8 +460,6 @@ function processItem(RepurposeItem $item, string $caption = 'My caption'): void
'status' => PostStatus::Draft,
]);
- // In draft mode the draft is the deliverable, so a late failure must not
- // throw away work the user can already see and publish.
(new ProcessRepurposeItem($item, REPURPOSE_VIDEO_URL, 'caption'))
->failed(new RuntimeException('gave up'));
@@ -473,9 +470,6 @@ function processItem(RepurposeItem $item, string $caption = 'My caption'): void
test('the stored error never carries the signed source url', function () {
$item = repurposeWithTwoDestinations();
- // A CDN download URL is a short-lived credential: Meta signs it with oh/oe
- // query parameters. Guzzle puts the whole URL in its message, and the item
- // error is exposed through the UI, the public API and MCP.
$message = 'cURL error 28: Operation timed out for '.REPURPOSE_VIDEO_URL.'?oh=SECRETSIG&oe=68B0';
(new ProcessRepurposeItem($item, REPURPOSE_VIDEO_URL.'?oh=SECRETSIG&oe=68B0', 'caption'))
@@ -488,10 +482,6 @@ function processItem(RepurposeItem $item, string $caption = 'My caption'): void
$item = repurposeWithTwoDestinations();
fakeVideoDownload();
- // Skipped items carry no posts, so the later "already has posts" guards do
- // not catch them. Only the terminal check does — without it, a video that
- // was deliberately skipped (already published through TryPost, or with no
- // downloadable file) gets replicated on the next delivery of the job.
$item->update(['status' => ItemStatus::Skipped, 'reason' => ItemReason::PublishedViaTrypost]);
(new ProcessRepurposeItem($item->fresh(), REPURPOSE_VIDEO_URL, 'caption'))
diff --git a/tests/Feature/Repurpose/SourceFetcherTest.php b/tests/Feature/Repurpose/SourceFetcherTest.php
index e90dcc4f1..51cbfe4d3 100644
--- a/tests/Feature/Repurpose/SourceFetcherTest.php
+++ b/tests/Feature/Repurpose/SourceFetcherTest.php
@@ -274,8 +274,6 @@ function fetchFor(SocialAccount $account, array $formats, $since = null): array
instagramGraph().'/*/media*' => function () use (&$attempt) {
$attempt++;
- // Graph rejects the whole read when one requested field is not
- // available to the token's login type, answering with code 100.
return $attempt === 1
? Http::response(['error' => ['code' => 100, 'message' => 'Unsupported get request']], 400)
: Http::response(['data' => [[
@@ -295,9 +293,6 @@ function fetchFor(SocialAccount $account, array $formats, $since = null): array
expect($attempt)->toBe(2)
->and($media)->toHaveCount(1)
->and($media[0]->id)->toBe('m1')
- // The reduced set carries neither media_product_type nor caption, so
- // every video reads as a Reel and the caption arrives empty. That is the
- // documented cost of not going dark, not an oversight.
->and($media[0]->format)->toBe(SourceFormat::Reel)
->and($media[0]->caption)->toBe('');
});
diff --git a/tests/Feature/Repurpose/SourceInvariantsTest.php b/tests/Feature/Repurpose/SourceInvariantsTest.php
index abab3a6a9..207958ba3 100644
--- a/tests/Feature/Repurpose/SourceInvariantsTest.php
+++ b/tests/Feature/Repurpose/SourceInvariantsTest.php
@@ -17,10 +17,6 @@
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
-/**
- * The database enforces both of these. These tests are about the user never
- * meeting it: every surface has to say no first, in words.
- */
beforeEach(function () {
config()->set('trypost.allow_multiple_social_accounts', true);
@@ -208,13 +204,9 @@ function selfDestination(SocialAccount $account): array
['social_account_id' => $this->source->id],
];
- // The check moved out of rules() into withValidator: a rule object is built
- // before anything is validated, so the id it compared against was still raw
- // request input. Here both sides have already passed `uuid`.
expect(fn () => SourceIsNotADestination::assert($destinations, $this->source->id))
->toThrow(ValidationException::class);
- // A source that appears nowhere in the list passes without comment.
SourceIsNotADestination::assert(
[['social_account_id' => $this->other->id]],
$this->source->id,
diff --git a/tests/Feature/Repurpose/TranslationKeysTest.php b/tests/Feature/Repurpose/TranslationKeysTest.php
index e4417157d..c66844b59 100644
--- a/tests/Feature/Repurpose/TranslationKeysTest.php
+++ b/tests/Feature/Repurpose/TranslationKeysTest.php
@@ -8,10 +8,6 @@
use App\Enums\Repurpose\Status;
use App\Support\Repurpose\Templates;
-/**
- * The repurpose screens build translation keys from enum values, so a new case
- * without a string renders the raw key to the user instead of failing loudly.
- */
function repurposeStrings(string $locale): array
{
return require dirname(__DIR__, 3)."/lang/{$locale}/repurposes.php";
diff --git a/tests/Feature/Repurpose/WebTest.php b/tests/Feature/Repurpose/WebTest.php
index 0b899ade8..220a5263a 100644
--- a/tests/Feature/Repurpose/WebTest.php
+++ b/tests/Feature/Repurpose/WebTest.php
@@ -224,9 +224,6 @@ function destinationPayload(SocialAccount $account): array
$this->tiktok->update(['is_active' => false]);
- // This used to be rejected. Switching an account off means "don't post
- // here", not "this repurpose is invalid" — ProcessRepurposeItem skips such
- // a destination, and activation still demands one usable destination.
$this->actingAs($this->user)
->put(route('app.repurposes.update', $repurpose), [
'destinations' => [destinationPayload($this->tiktok)],
@@ -330,9 +327,6 @@ function destinationPayload(SocialAccount $account): array
$this->tiktok->update(['is_active' => false]);
- // Switching an account off means "don't post here", which the job already
- // honours by skipping it. Rejecting the payload instead would stop the user
- // saving any edit at all, because the editor round-trips the whole list.
$this->actingAs($this->user)
->put(route('app.repurposes.update', $repurpose), [
'source_social_account_id' => $this->source->id,
@@ -578,8 +572,6 @@ function destinationPayload(SocialAccount $account): array
->get(route('app.repurposes.show', $repurpose))
->assertInertia(fn (AssertableInertia $page) => $page
->has('items.data.0.posts', 2)
- // posts() carries no explicit ordering, so assert on the set rather
- // than on which one the database happened to return first.
->where('items.data.0.posts', fn (Collection $posts): bool => $posts
->pluck('platforms.0.status')
->sort()
@@ -596,9 +588,6 @@ function destinationPayload(SocialAccount $account): array
$this->tiktok->update(['is_active' => false]);
- // The editor round-trips whatever it was given. An account missing from
- // destinationAccounts is filtered out of the form, so the next save would
- // erase a destination the user only paused.
$this->actingAs($this->user)
->get(route('app.repurposes.show', $repurpose))
->assertOk()
From 5f30b9e2e686e13eb9ad6b6f55b5ff444eab2b83 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 12:26:52 -0300
Subject: [PATCH 093/114] Finish moving the cross-field checks out of rules()
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/Actions/Repurpose/CreateRepurpose.php | 6 +-
.../Api/Repurpose/StoreRepurposeRequest.php | 27 ++++-----
.../Api/Repurpose/UpdateRepurposeRequest.php | 22 ++++----
.../App/Repurpose/UpdateRepurposeRequest.php | 22 ++++----
.../Repurpose/CreateRepurposeRequest.php | 4 --
.../Repurpose/UpdateRepurposeRequest.php | 6 --
.../Tools/Repurpose/CreateRepurposeTool.php | 8 +++
.../Tools/Repurpose/UpdateRepurposeTool.php | 9 +++
app/Rules/Repurpose/SourceIsFree.php | 38 -------------
app/Support/Repurpose/SourceIsFree.php | 56 +++++++++++++++++++
.../Repurpose/SourceInvariantsTest.php | 37 +++---------
11 files changed, 121 insertions(+), 114 deletions(-)
delete mode 100644 app/Rules/Repurpose/SourceIsFree.php
create mode 100644 app/Support/Repurpose/SourceIsFree.php
diff --git a/app/Actions/Repurpose/CreateRepurpose.php b/app/Actions/Repurpose/CreateRepurpose.php
index 15115c51e..fd84ac578 100644
--- a/app/Actions/Repurpose/CreateRepurpose.php
+++ b/app/Actions/Repurpose/CreateRepurpose.php
@@ -20,8 +20,8 @@ class CreateRepurpose
*/
public static function execute(Workspace $workspace, User $user, array $data): Repurpose
{
- $sourceAccountId = (string) data_get($data, 'source_social_account_id');
- $sourceFormat = SourceFormat::tryFrom((string) data_get($data, 'source_format')) ?? SourceFormat::Reel;
+ $sourceAccountId = data_get($data, 'source_social_account_id');
+ $sourceFormat = SourceFormat::from(data_get($data, 'source_format', SourceFormat::Reel->value));
if (self::existingFor($workspace, $sourceAccountId, $sourceFormat) !== null) {
throw ValidationException::withMessages([
@@ -35,7 +35,7 @@ public static function execute(Workspace $workspace, User $user, array $data): R
'user_id' => $user->id,
'source_social_account_id' => $sourceAccountId,
'source_format' => $sourceFormat,
- 'publish_mode' => PublishMode::tryFrom((string) data_get($data, 'publish_mode')) ?? PublishMode::Publish,
+ 'publish_mode' => PublishMode::from(data_get($data, 'publish_mode', PublishMode::Publish->value)),
'destinations' => data_get($data, 'destinations', []),
'status' => Status::Draft,
]);
diff --git a/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php b/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php
index 66d2e2713..a7cca9c84 100644
--- a/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php
+++ b/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php
@@ -9,9 +9,9 @@
use App\Enums\Repurpose\SourceFormat;
use App\Enums\SocialAccount\Platform;
use App\Rules\ContentTypeMatchesPlatform;
-use App\Rules\Repurpose\SourceIsFree;
use App\Services\Repurpose\SourceFetcherFactory;
use App\Support\Repurpose\DestinationMetaRules;
+use App\Support\Repurpose\SourceIsFree;
use App\Support\Repurpose\SourceIsNotADestination;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -29,18 +29,6 @@ private function workspaceId(): ?string
return $this->user()->currentWorkspace?->id;
}
- private function sourceAccountId(): ?string
- {
- $id = $this->input('source_social_account_id');
-
- return is_string($id) ? $id : null;
- }
-
- private function sourceFormat(): SourceFormat
- {
- return SourceFormat::tryFrom((string) $this->input('source_format')) ?? SourceFormat::Reel;
- }
-
/**
* @return array
*/
@@ -58,7 +46,6 @@ public function rules(): array
fn (Platform $platform): string => $platform->value,
SourceFetcherFactory::supportedPlatforms(),
)),
- new SourceIsFree($this->workspaceId(), $this->sourceFormat()),
],
'source_format' => ['sometimes', Rule::enum(SourceFormat::class)],
'publish_mode' => ['sometimes', Rule::enum(PublishMode::class)],
@@ -115,10 +102,20 @@ public function withValidator(Validator $validator): void
return;
}
+ $sourceAccountId = $this->input('source_social_account_id');
+
+ SourceIsFree::addErrors(
+ $validator,
+ $this->workspaceId(),
+ $sourceAccountId,
+ SourceFormat::from($this->input('source_format', SourceFormat::Reel->value)),
+ null,
+ );
+
SourceIsNotADestination::addErrors(
$validator,
(array) $this->input('destinations', []),
- $this->input('source_social_account_id'),
+ $sourceAccountId,
);
});
}
diff --git a/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php b/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php
index d144e3505..00be26aea 100644
--- a/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php
+++ b/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php
@@ -10,9 +10,9 @@
use App\Enums\SocialAccount\Platform;
use App\Models\Repurpose;
use App\Rules\ContentTypeMatchesPlatform;
-use App\Rules\Repurpose\SourceIsFree;
use App\Services\Repurpose\SourceFetcherFactory;
use App\Support\Repurpose\DestinationMetaRules;
+use App\Support\Repurpose\SourceIsFree;
use App\Support\Repurpose\SourceIsNotADestination;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -35,13 +35,6 @@ private function repurpose(): Repurpose
return $this->route('repurpose');
}
- private function sourceFormat(): SourceFormat
- {
- return SourceFormat::tryFrom((string) $this->input('source_format'))
- ?? $this->repurpose()->source_format
- ?? SourceFormat::Reel;
- }
-
/**
* @return array
*/
@@ -59,7 +52,6 @@ public function rules(): array
fn (Platform $platform): string => $platform->value,
SourceFetcherFactory::supportedPlatforms(),
)),
- new SourceIsFree($this->workspaceId(), $this->sourceFormat(), $this->repurpose()->id),
],
'source_format' => ['sometimes', Rule::enum(SourceFormat::class)],
'publish_mode' => ['sometimes', Rule::enum(PublishMode::class)],
@@ -116,10 +108,20 @@ public function withValidator(Validator $validator): void
return;
}
+ $sourceAccountId = $this->input('source_social_account_id', $this->repurpose()->source_social_account_id);
+
+ SourceIsFree::addErrors(
+ $validator,
+ $this->workspaceId(),
+ $sourceAccountId,
+ SourceFormat::from($this->input('source_format', $this->repurpose()->source_format->value)),
+ $this->repurpose()->id,
+ );
+
SourceIsNotADestination::addErrors(
$validator,
(array) $this->input('destinations', []),
- $this->input('source_social_account_id', $this->route('repurpose')->source_social_account_id),
+ $sourceAccountId,
);
});
}
diff --git a/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php b/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php
index 6bcf41a71..c4b606210 100644
--- a/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php
+++ b/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php
@@ -10,9 +10,9 @@
use App\Enums\SocialAccount\Platform;
use App\Models\Repurpose;
use App\Rules\ContentTypeMatchesPlatform;
-use App\Rules\Repurpose\SourceIsFree;
use App\Services\Repurpose\SourceFetcherFactory;
use App\Support\Repurpose\DestinationMetaRules;
+use App\Support\Repurpose\SourceIsFree;
use App\Support\Repurpose\SourceIsNotADestination;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -35,13 +35,6 @@ private function repurpose(): Repurpose
return $this->route('repurpose');
}
- private function sourceFormat(): SourceFormat
- {
- return SourceFormat::tryFrom((string) $this->input('source_format'))
- ?? $this->repurpose()->source_format
- ?? SourceFormat::Reel;
- }
-
/**
* @return array
*/
@@ -59,7 +52,6 @@ public function rules(): array
fn (Platform $platform): string => $platform->value,
SourceFetcherFactory::supportedPlatforms(),
)),
- new SourceIsFree($this->workspaceId(), $this->sourceFormat(), $this->repurpose()->id),
],
'source_format' => ['sometimes', Rule::enum(SourceFormat::class)],
'publish_mode' => ['sometimes', Rule::enum(PublishMode::class)],
@@ -116,10 +108,20 @@ public function withValidator(Validator $validator): void
return;
}
+ $sourceAccountId = $this->input('source_social_account_id', $this->repurpose()->source_social_account_id);
+
+ SourceIsFree::addErrors(
+ $validator,
+ $this->workspaceId(),
+ $sourceAccountId,
+ SourceFormat::from($this->input('source_format', $this->repurpose()->source_format->value)),
+ $this->repurpose()->id,
+ );
+
SourceIsNotADestination::addErrors(
$validator,
(array) $this->input('destinations', []),
- $this->input('source_social_account_id', $this->route('repurpose')->source_social_account_id),
+ $sourceAccountId,
);
});
}
diff --git a/app/Mcp/Requests/Repurpose/CreateRepurposeRequest.php b/app/Mcp/Requests/Repurpose/CreateRepurposeRequest.php
index 78b8619d1..c3c8852d0 100644
--- a/app/Mcp/Requests/Repurpose/CreateRepurposeRequest.php
+++ b/app/Mcp/Requests/Repurpose/CreateRepurposeRequest.php
@@ -9,7 +9,6 @@
use App\Enums\Repurpose\SourceFormat;
use App\Enums\SocialAccount\Platform;
use App\Rules\ContentTypeMatchesPlatform;
-use App\Rules\Repurpose\SourceIsFree;
use App\Services\Repurpose\SourceFetcherFactory;
use App\Support\Repurpose\DestinationMetaRules;
use Illuminate\Validation\Rule;
@@ -24,8 +23,6 @@ class CreateRepurposeRequest
*/
public static function rules(?string $workspaceId = null, array $payload = []): array
{
- $sourceAccountId = data_get($payload, 'source_social_account_id');
- $sourceFormat = SourceFormat::tryFrom((string) data_get($payload, 'source_format')) ?? SourceFormat::Reel;
return [
'source_social_account_id' => [
@@ -39,7 +36,6 @@ public static function rules(?string $workspaceId = null, array $payload = []):
fn (Platform $platform): string => $platform->value,
SourceFetcherFactory::supportedPlatforms(),
)),
- new SourceIsFree($workspaceId, $sourceFormat),
],
'source_format' => ['sometimes', Rule::enum(SourceFormat::class)],
'publish_mode' => ['sometimes', Rule::enum(PublishMode::class)],
diff --git a/app/Mcp/Requests/Repurpose/UpdateRepurposeRequest.php b/app/Mcp/Requests/Repurpose/UpdateRepurposeRequest.php
index c3a200a9a..c78318f58 100644
--- a/app/Mcp/Requests/Repurpose/UpdateRepurposeRequest.php
+++ b/app/Mcp/Requests/Repurpose/UpdateRepurposeRequest.php
@@ -10,7 +10,6 @@
use App\Enums\SocialAccount\Platform;
use App\Models\Repurpose;
use App\Rules\ContentTypeMatchesPlatform;
-use App\Rules\Repurpose\SourceIsFree;
use App\Services\Repurpose\SourceFetcherFactory;
use App\Support\Repurpose\DestinationMetaRules;
use Illuminate\Validation\Rule;
@@ -25,10 +24,6 @@ class UpdateRepurposeRequest
*/
public static function rules(?string $workspaceId = null, ?Repurpose $repurpose = null, array $payload = []): array
{
- $sourceAccountId = data_get($payload, 'source_social_account_id', $repurpose?->source_social_account_id);
- $sourceFormat = SourceFormat::tryFrom((string) data_get($payload, 'source_format'))
- ?? $repurpose?->source_format
- ?? SourceFormat::Reel;
return [
'repurpose_id' => ['required', 'string', 'uuid'],
@@ -43,7 +38,6 @@ public static function rules(?string $workspaceId = null, ?Repurpose $repurpose
fn (Platform $platform): string => $platform->value,
SourceFetcherFactory::supportedPlatforms(),
)),
- new SourceIsFree($workspaceId, $sourceFormat, $repurpose?->id),
],
'source_format' => ['sometimes', Rule::enum(SourceFormat::class)],
'publish_mode' => ['sometimes', Rule::enum(PublishMode::class)],
diff --git a/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php b/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php
index 4033d3b75..0763ff1fb 100644
--- a/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php
+++ b/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php
@@ -5,10 +5,12 @@
namespace App\Mcp\Tools\Repurpose;
use App\Actions\Repurpose\CreateRepurpose;
+use App\Enums\Repurpose\SourceFormat;
use App\Http\Resources\Api\RepurposeResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Mcp\Requests\Repurpose\CreateRepurposeRequest;
use App\Models\Workspace;
+use App\Support\Repurpose\SourceIsFree;
use App\Support\Repurpose\SourceIsNotADestination;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\ValidationException;
@@ -33,6 +35,12 @@ public function handle(Request $request): Response|ResponseFactory
$validated = $request->validate(CreateRepurposeRequest::rules($workspace->id, $request->all()));
+ SourceIsFree::assert(
+ $workspace->id,
+ data_get($validated, 'source_social_account_id'),
+ SourceFormat::from(data_get($validated, 'source_format', SourceFormat::Reel->value)),
+ );
+
SourceIsNotADestination::assert(
(array) data_get($validated, 'destinations', []),
data_get($validated, 'source_social_account_id'),
diff --git a/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php b/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php
index 35e671713..fe672c9ed 100644
--- a/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php
+++ b/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php
@@ -5,6 +5,7 @@
namespace App\Mcp\Tools\Repurpose;
use App\Actions\Repurpose\UpdateRepurpose;
+use App\Enums\Repurpose\SourceFormat;
use App\Http\Resources\Api\RepurposeResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Mcp\Concerns\ResolvesWorkspaceRepurpose;
@@ -12,6 +13,7 @@
use App\Mcp\Requests\Repurpose\UpdateRepurposeRequest;
use App\Models\Repurpose;
use App\Models\Workspace;
+use App\Support\Repurpose\SourceIsFree;
use App\Support\Repurpose\SourceIsNotADestination;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
@@ -42,6 +44,13 @@ public function handle(Request $request): Response|ResponseFactory
$validated = $request->validate(UpdateRepurposeRequest::rules($workspace->id, $repurpose, $request->all()));
+ SourceIsFree::assert(
+ $workspace->id,
+ data_get($validated, 'source_social_account_id', $repurpose->source_social_account_id),
+ SourceFormat::from(data_get($validated, 'source_format', $repurpose->source_format->value)),
+ $repurpose->id,
+ );
+
SourceIsNotADestination::assert(
(array) data_get($validated, 'destinations', []),
data_get($validated, 'source_social_account_id', $repurpose->source_social_account_id),
diff --git a/app/Rules/Repurpose/SourceIsFree.php b/app/Rules/Repurpose/SourceIsFree.php
deleted file mode 100644
index ce5d189a2..000000000
--- a/app/Rules/Repurpose/SourceIsFree.php
+++ /dev/null
@@ -1,38 +0,0 @@
-workspaceId === null || ! Str::isUuid((string) $value)) {
- return;
- }
-
- $taken = Repurpose::query()
- ->where('workspace_id', $this->workspaceId)
- ->where('source_social_account_id', (string) $value)
- ->where('source_format', $this->format)
- ->when($this->ignoreRepurposeId !== null, fn ($query) => $query->whereKeyNot($this->ignoreRepurposeId))
- ->exists();
-
- if ($taken) {
- $fail(__('repurposes.errors.source_already_used'));
- }
- }
-}
diff --git a/app/Support/Repurpose/SourceIsFree.php b/app/Support/Repurpose/SourceIsFree.php
new file mode 100644
index 000000000..175ad7bad
--- /dev/null
+++ b/app/Support/Repurpose/SourceIsFree.php
@@ -0,0 +1,56 @@
+errors()->add('source_social_account_id', __('repurposes.errors.source_already_used'));
+ }
+ }
+
+ public static function assert(
+ ?string $workspaceId,
+ ?string $sourceAccountId,
+ SourceFormat $format,
+ ?string $ignoreRepurposeId = null,
+ ): void {
+ if (self::isTaken($workspaceId, $sourceAccountId, $format, $ignoreRepurposeId)) {
+ throw ValidationException::withMessages([
+ 'source_social_account_id' => __('repurposes.errors.source_already_used'),
+ ]);
+ }
+ }
+
+ private static function isTaken(
+ ?string $workspaceId,
+ ?string $sourceAccountId,
+ SourceFormat $format,
+ ?string $ignoreRepurposeId,
+ ): bool {
+ if ($workspaceId === null || $sourceAccountId === null) {
+ return false;
+ }
+
+ return Repurpose::query()
+ ->where('workspace_id', $workspaceId)
+ ->where('source_social_account_id', $sourceAccountId)
+ ->where('source_format', $format)
+ ->when($ignoreRepurposeId !== null, fn ($query) => $query->whereKeyNot($ignoreRepurposeId))
+ ->exists();
+ }
+}
diff --git a/tests/Feature/Repurpose/SourceInvariantsTest.php b/tests/Feature/Repurpose/SourceInvariantsTest.php
index 207958ba3..8558304a1 100644
--- a/tests/Feature/Repurpose/SourceInvariantsTest.php
+++ b/tests/Feature/Repurpose/SourceInvariantsTest.php
@@ -12,7 +12,7 @@
use App\Mcp\Tools\Repurpose\UpdateRepurposeTool;
use App\Models\Repurpose;
use App\Models\SocialAccount;
-use App\Rules\Repurpose\SourceIsFree;
+use App\Support\Repurpose\SourceIsFree;
use App\Support\Repurpose\SourceIsNotADestination;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
@@ -152,50 +152,31 @@ function selfDestination(SocialAccount $account): array
]))->toThrow(ValidationException::class, __('repurposes.errors.source_already_used'));
});
-test('the rule itself refuses a source and format already watched', function () {
+test('the helper itself refuses a source and format already watched', function () {
Repurpose::factory()->create([
'workspace_id' => $this->workspace->id,
'source_social_account_id' => $this->other->id,
'source_format' => SourceFormat::Reel,
]);
- $failures = [];
- $collect = function (string $message) use (&$failures): void {
- $failures[] = $message;
- };
-
- (new SourceIsFree($this->workspace->id, SourceFormat::Reel))
- ->validate('source_social_account_id', $this->other->id, $collect);
-
- expect($failures)->toBe([__('repurposes.errors.source_already_used')]);
-
- $failures = [];
-
- (new SourceIsFree($this->workspace->id, SourceFormat::Story))
- ->validate('source_social_account_id', $this->other->id, $collect);
+ expect(fn () => SourceIsFree::assert($this->workspace->id, $this->other->id, SourceFormat::Reel))
+ ->toThrow(ValidationException::class);
- (new SourceIsFree($this->workspace->id, SourceFormat::Reel))
- ->validate('source_social_account_id', $this->source->id, $collect);
+ SourceIsFree::assert($this->workspace->id, $this->other->id, SourceFormat::Story);
- expect($failures)->toBe([]);
+ expect(true)->toBeTrue();
});
-test('the rule itself refuses a repurpose that already holds the pair, unless it is the one being edited', function () {
+test('the helper lets a repurpose keep the pair it already holds', function () {
$mine = Repurpose::factory()->create([
'workspace_id' => $this->workspace->id,
'source_social_account_id' => $this->other->id,
'source_format' => SourceFormat::Reel,
]);
- $failures = [];
- $collect = function (string $message) use (&$failures): void {
- $failures[] = $message;
- };
+ SourceIsFree::assert($this->workspace->id, $this->other->id, SourceFormat::Reel, $mine->id);
- (new SourceIsFree($this->workspace->id, SourceFormat::Reel, $mine->id))
- ->validate('source_social_account_id', $this->other->id, $collect);
-
- expect($failures)->toBe([]);
+ expect(true)->toBeTrue();
});
test('the helper itself refuses the source as its own destination', function () {
From 429fff236508b3c5f2167e0177fde7ec9e467c70 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 12:31:54 -0300
Subject: [PATCH 094/114] Drop the unreachable content-type fallback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/Enums/Repurpose/SourceFormat.php | 5 +++--
app/Http/Controllers/App/RepurposeController.php | 11 +++--------
2 files changed, 6 insertions(+), 10 deletions(-)
diff --git a/app/Enums/Repurpose/SourceFormat.php b/app/Enums/Repurpose/SourceFormat.php
index ba79c0a83..69b345afa 100644
--- a/app/Enums/Repurpose/SourceFormat.php
+++ b/app/Enums/Repurpose/SourceFormat.php
@@ -6,6 +6,7 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
+use Illuminate\Support\Arr;
enum SourceFormat: string
{
@@ -29,7 +30,7 @@ public static function forPlatform(Platform $platform): array
};
}
- public function defaultContentTypeFor(Platform $platform): ?ContentType
+ public function defaultContentTypeFor(Platform $platform): ContentType
{
$candidates = match ($this) {
self::Reel, self::Video => [ContentType::InstagramReel, ContentType::FacebookReel, ContentType::TikTokVideo, ContentType::YouTubeShort],
@@ -44,7 +45,7 @@ public function defaultContentTypeFor(Platform $platform): ?ContentType
}
}
- return $available[0] ?? ContentType::defaultFor($platform);
+ return Arr::first($available) ?? ContentType::defaultFor($platform);
}
/**
diff --git a/app/Http/Controllers/App/RepurposeController.php b/app/Http/Controllers/App/RepurposeController.php
index 9bd980c40..1fbc92e91 100644
--- a/app/Http/Controllers/App/RepurposeController.php
+++ b/app/Http/Controllers/App/RepurposeController.php
@@ -165,14 +165,9 @@ private function sourceFormats(Repurpose $repurpose): array
private function recommendedFormats(Collection $accounts, SourceFormat $sourceFormat): array
{
return $accounts
- ->mapWithKeys(function (SocialAccount $account) use ($sourceFormat): array {
- $contentType = $sourceFormat->defaultContentTypeFor($account->platform)
- ?? SourceFormat::videoContentTypesFor($account->platform)[0]
- ?? null;
-
- return [$account->id => $contentType?->value];
- })
- ->filter()
+ ->mapWithKeys(fn (SocialAccount $account): array => [
+ $account->id => $sourceFormat->defaultContentTypeFor($account->platform)->value,
+ ])
->all();
}
From 8aa74c53835b311557aa834d6685d081e71ad144 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 12:35:47 -0300
Subject: [PATCH 095/114] Split the polling job into steps that each do one
thing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/Jobs/Repurpose/PollRepurposeSource.php | 80 +++++++++++-----------
app/Services/Repurpose/SourceMedia.php | 7 ++
2 files changed, 47 insertions(+), 40 deletions(-)
diff --git a/app/Jobs/Repurpose/PollRepurposeSource.php b/app/Jobs/Repurpose/PollRepurposeSource.php
index 9db2b4955..7af0ae7a9 100644
--- a/app/Jobs/Repurpose/PollRepurposeSource.php
+++ b/app/Jobs/Repurpose/PollRepurposeSource.php
@@ -16,6 +16,7 @@
use App\Services\Repurpose\SourceFetcherFactory;
use App\Services\Repurpose\SourceMedia;
use App\Services\Social\TokenRedactor;
+use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -55,7 +56,7 @@ public function handle(SourceFetcherFactory $fetchers): void
}
if ($this->account->disconnected_at !== null || $this->account->is_active === false) {
- $this->reschedule($repurposes, $this->interval());
+ $this->reschedule($repurposes);
return;
}
@@ -75,10 +76,10 @@ public function handle(SourceFetcherFactory $fetchers): void
$publishedByUs = $this->idsPublishedByTryPost($media);
foreach ($repurposes as $repurpose) {
- $this->logMedia($repurpose, $media, $publishedByUs);
+ $this->queueNewMedia($repurpose, $media, $publishedByUs);
}
- $this->markPolled($repurposes, $this->interval());
+ $this->markPolled($repurposes);
}
/**
@@ -104,7 +105,7 @@ private function watchedFormats(Collection $repurposes): array
/**
* @param Collection $repurposes
*/
- private function earliestWatermark(Collection $repurposes): mixed
+ private function earliestWatermark(Collection $repurposes): ?CarbonInterface
{
return $repurposes->pluck('activated_at')->filter()->min();
}
@@ -113,46 +114,45 @@ private function earliestWatermark(Collection $repurposes): mixed
* @param array $media
* @param array $publishedByUs
*/
- private function logMedia(Repurpose $repurpose, array $media, array $publishedByUs): void
+ private function queueNewMedia(Repurpose $repurpose, array $media, array $publishedByUs): void
{
- $matching = array_values(array_filter(
- $media,
- fn (SourceMedia $entry): bool => $entry->format === $repurpose->source_format
- && ($repurpose->activated_at === null || $entry->createdAt === null || $entry->createdAt->greaterThan($repurpose->activated_at)),
- ));
+ collect($media)
+ ->filter(fn (SourceMedia $entry): bool => $entry->format === $repurpose->source_format)
+ ->filter(fn (SourceMedia $entry): bool => $entry->isNewerThan($repurpose->activated_at))
+ ->each(fn (SourceMedia $entry) => $this->queue($repurpose, $entry, $publishedByUs));
+ }
- if ($matching === []) {
+ /**
+ * @param array $publishedByUs
+ */
+ private function queue(Repurpose $repurpose, SourceMedia $entry, array $publishedByUs): void
+ {
+ $item = RepurposeItem::firstOrCreate(
+ ['repurpose_id' => $repurpose->id, 'source_media_id' => $entry->id],
+ [
+ 'status' => ItemStatus::Pending,
+ 'source_permalink' => $entry->permalink,
+ 'source_created_at' => $entry->createdAt,
+ ],
+ );
+
+ if (! $item->wasRecentlyCreated) {
return;
}
- foreach ($matching as $entry) {
- $item = RepurposeItem::firstOrCreate(
- ['repurpose_id' => $repurpose->id, 'source_media_id' => $entry->id],
- [
- 'status' => ItemStatus::Pending,
- 'source_permalink' => $entry->permalink,
- 'source_created_at' => $entry->createdAt,
- ],
- );
-
- if (! $item->wasRecentlyCreated) {
- continue;
- }
+ if (in_array($entry->id, $publishedByUs, true)) {
+ $item->update(['status' => ItemStatus::Skipped, 'reason' => ItemReason::PublishedViaTrypost]);
- if (in_array($entry->id, $publishedByUs, true)) {
- $item->update(['status' => ItemStatus::Skipped, 'reason' => ItemReason::PublishedViaTrypost]);
-
- continue;
- }
-
- if (blank($entry->downloadUrl)) {
- $item->update(['status' => ItemStatus::Skipped, 'reason' => ItemReason::MediaUrlMissing]);
+ return;
+ }
- continue;
- }
+ if (blank($entry->downloadUrl)) {
+ $item->update(['status' => ItemStatus::Skipped, 'reason' => ItemReason::MediaUrlMissing]);
- ProcessRepurposeItem::dispatch($item, (string) $entry->downloadUrl, $entry->caption);
+ return;
}
+
+ ProcessRepurposeItem::dispatch($item, (string) $entry->downloadUrl, $entry->caption);
}
/**
@@ -189,7 +189,7 @@ private function recordFailure(Collection $repurposes, Throwable $exception): vo
'next_poll_at' => now()->addMinutes($throttled ? $this->backoff() : $this->interval()),
]);
- Log::warning('Repurpose polling failed', [
+ Log::error('Repurpose polling failed', [
'social_account_id' => $this->account->id,
'message' => $message,
]);
@@ -198,22 +198,22 @@ private function recordFailure(Collection $repurposes, Throwable $exception): vo
/**
* @param Collection $repurposes
*/
- private function reschedule(Collection $repurposes, int $minutes): void
+ private function reschedule(Collection $repurposes): void
{
Repurpose::whereKey($repurposes->modelKeys())->update([
- 'next_poll_at' => now()->addMinutes($minutes),
+ 'next_poll_at' => now()->addMinutes($this->interval()),
]);
}
/**
* @param Collection $repurposes
*/
- private function markPolled(Collection $repurposes, int $minutes): void
+ private function markPolled(Collection $repurposes): void
{
Repurpose::whereKey($repurposes->modelKeys())->update([
'last_error' => null,
'last_polled_at' => now(),
- 'next_poll_at' => now()->addMinutes($minutes),
+ 'next_poll_at' => now()->addMinutes($this->interval()),
]);
}
diff --git a/app/Services/Repurpose/SourceMedia.php b/app/Services/Repurpose/SourceMedia.php
index 94715571a..ed2e65e1e 100644
--- a/app/Services/Repurpose/SourceMedia.php
+++ b/app/Services/Repurpose/SourceMedia.php
@@ -17,4 +17,11 @@ public function __construct(
public ?string $permalink,
public ?CarbonInterface $createdAt,
) {}
+
+ public function isNewerThan(?CarbonInterface $watermark): bool
+ {
+ return $watermark === null
+ || $this->createdAt === null
+ || $this->createdAt->greaterThan($watermark);
+ }
}
From 80023d16833840d36c92d54d8d7818112c64a1c6 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 12:39:24 -0300
Subject: [PATCH 096/114] Simplify the caption adapter and the Facebook fetcher
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/Services/Repurpose/CaptionAdapter.php | 39 +++++++----
.../Repurpose/FacebookSourceFetcher.php | 65 ++++++++++---------
.../Repurpose/InstagramSourceFetcher.php | 3 +-
app/Services/Repurpose/MetaSourceFetcher.php | 12 ++++
4 files changed, 74 insertions(+), 45 deletions(-)
diff --git a/app/Services/Repurpose/CaptionAdapter.php b/app/Services/Repurpose/CaptionAdapter.php
index 29051093e..44c60d3d5 100644
--- a/app/Services/Repurpose/CaptionAdapter.php
+++ b/app/Services/Repurpose/CaptionAdapter.php
@@ -73,31 +73,44 @@ private function shorten(Workspace $workspace, ?User $user, string $caption, Pla
$shortened = trim((string) $result->text);
- return $shortened !== '' && $this->fits($shortened, $platform) ? $shortened : null;
+ if ($shortened === '' || ! $this->fits($shortened, $platform)) {
+ return null;
+ }
+
+ return $shortened;
}
private function truncate(string $caption, Platform $platform): string
{
- while (! $this->fits($caption, $platform)) {
- $caption = $this->cutAtWord($caption, $this->fittingLength($caption, $platform));
- }
+ $longest = $this->longestFittingPrefix($caption, $platform);
+ $atBoundary = $this->cutAtWord($longest);
- return $caption;
+ return $this->fits($atBoundary, $platform) ? $atBoundary : $longest;
}
- private function fittingLength(string $caption, Platform $platform): int
+ private function longestFittingPrefix(string $caption, Platform $platform): string
{
- $length = mb_strlen($caption);
- $scaled = $length * $platform->maxContentLength() / mb_strlen($this->sent($caption, $platform));
+ $low = 0;
+ $high = mb_strlen($caption);
+
+ while ($low < $high) {
+ $middle = intdiv($low + $high + 1, 2);
+
+ if ($this->fits(mb_substr($caption, 0, $middle), $platform)) {
+ $low = $middle;
+ } else {
+ $high = $middle - 1;
+ }
+ }
- return min((int) $scaled, $length - 1);
+ return mb_substr($caption, 0, $low);
}
- private function cutAtWord(string $caption, int $limit): string
+ private function cutAtWord(string $caption): string
{
- $cut = rtrim(mb_substr($caption, 0, $limit));
- $boundary = rtrim(Str::beforeLast($cut, ' '));
+ $trimmed = rtrim($caption);
+ $atBoundary = rtrim(Str::beforeLast($trimmed, ' '));
- return $boundary === '' ? $cut : $boundary;
+ return $atBoundary !== '' ? $atBoundary : $trimmed;
}
}
diff --git a/app/Services/Repurpose/FacebookSourceFetcher.php b/app/Services/Repurpose/FacebookSourceFetcher.php
index 63f4a4e05..9590523ec 100644
--- a/app/Services/Repurpose/FacebookSourceFetcher.php
+++ b/app/Services/Repurpose/FacebookSourceFetcher.php
@@ -8,7 +8,6 @@
use App\Enums\Facebook\StoryStatus;
use App\Enums\Repurpose\SourceFormat;
use App\Models\SocialAccount;
-use Carbon\Carbon;
use Carbon\CarbonInterface;
class FacebookSourceFetcher extends MetaSourceFetcher
@@ -40,13 +39,11 @@ public function fetch(SocialAccount $account, ?CarbonInterface $since, array $fo
? $this->stories($account, $since)
: [];
- if ($reels !== [] && $videos !== []) {
- $reelIds = array_map(fn (SourceMedia $media): string => $media->id, $reels);
- $videos = array_values(array_filter(
- $videos,
- fn (SourceMedia $media): bool => ! in_array($media->id, $reelIds, true),
- ));
- }
+ $reelIds = array_map(fn (SourceMedia $media): string => $media->id, $reels);
+ $videos = array_values(array_filter(
+ $videos,
+ fn (SourceMedia $media): bool => ! in_array($media->id, $reelIds, true),
+ ));
return [...($wantsReels ? $reels : []), ...$videos, ...$stories];
}
@@ -70,7 +67,7 @@ private function videos(SocialAccount $account, string $edge, ?CarbonInterface $
downloadUrl: data_get($row, 'source'),
caption: (string) data_get($row, 'description', ''),
permalink: data_get($row, 'permalink_url'),
- createdAt: ($createdTime = data_get($row, 'created_time')) ? Carbon::parse($createdTime) : null,
+ createdAt: $this->timestamp($row, 'created_time'),
),
$rows,
);
@@ -87,29 +84,37 @@ private function stories(SocialAccount $account, ?CarbonInterface $since): array
'since' => $since?->getTimestamp(),
]);
- $stories = [];
-
- foreach ($rows as $row) {
- $mediaType = StoryMediaType::tryFrom((string) data_get($row, 'media_type'));
- $status = StoryStatus::tryFrom((string) data_get($row, 'status'));
-
- if ($mediaType !== StoryMediaType::Video || $status !== StoryStatus::Published) {
- continue;
- }
-
- $mediaId = (string) data_get($row, 'media_id');
+ return collect($rows)
+ ->filter(fn (array $row): bool => $this->isPublishedVideo($row))
+ ->map(fn (array $row): SourceMedia => $this->toStory($account, $row))
+ ->values()
+ ->all();
+ }
- $stories[] = new SourceMedia(
- id: (string) data_get($row, 'post_id', $mediaId),
- format: SourceFormat::Story,
- downloadUrl: $this->videoSource($account, $mediaId),
- caption: '',
- permalink: data_get($row, 'url'),
- createdAt: ($createdTime = data_get($row, 'creation_time')) ? Carbon::parse($createdTime) : null,
- );
- }
+ /**
+ * @param array $row
+ */
+ private function isPublishedVideo(array $row): bool
+ {
+ return StoryMediaType::tryFrom((string) data_get($row, 'media_type')) === StoryMediaType::Video
+ && StoryStatus::tryFrom((string) data_get($row, 'status')) === StoryStatus::Published;
+ }
- return $stories;
+ /**
+ * @param array $row
+ */
+ private function toStory(SocialAccount $account, array $row): SourceMedia
+ {
+ $mediaId = (string) data_get($row, 'media_id');
+
+ return new SourceMedia(
+ id: (string) data_get($row, 'post_id', $mediaId),
+ format: SourceFormat::Story,
+ downloadUrl: $this->videoSource($account, $mediaId),
+ caption: '',
+ permalink: data_get($row, 'url'),
+ createdAt: $this->timestamp($row, 'creation_time'),
+ );
}
private function videoSource(SocialAccount $account, string $videoId): ?string
diff --git a/app/Services/Repurpose/InstagramSourceFetcher.php b/app/Services/Repurpose/InstagramSourceFetcher.php
index a5d07b70b..21b3e8790 100644
--- a/app/Services/Repurpose/InstagramSourceFetcher.php
+++ b/app/Services/Repurpose/InstagramSourceFetcher.php
@@ -9,7 +9,6 @@
use App\Enums\Repurpose\SourceFormat;
use App\Enums\SocialAccount\Platform;
use App\Models\SocialAccount;
-use Carbon\Carbon;
use Carbon\CarbonInterface;
class InstagramSourceFetcher extends MetaSourceFetcher
@@ -69,7 +68,7 @@ private function toSourceMedia(array $row, ?SourceFormat $edgeFormat): SourceMed
downloadUrl: data_get($row, 'media_url'),
caption: (string) data_get($row, 'caption', ''),
permalink: data_get($row, 'permalink'),
- createdAt: ($timestamp = data_get($row, 'timestamp')) ? Carbon::parse($timestamp) : null,
+ createdAt: $this->timestamp($row, 'timestamp'),
);
}
diff --git a/app/Services/Repurpose/MetaSourceFetcher.php b/app/Services/Repurpose/MetaSourceFetcher.php
index 665a95790..ca08b9ab5 100644
--- a/app/Services/Repurpose/MetaSourceFetcher.php
+++ b/app/Services/Repurpose/MetaSourceFetcher.php
@@ -6,6 +6,8 @@
use App\Exceptions\Repurpose\SourceFetchException;
use App\Models\SocialAccount;
+use Carbon\Carbon;
+use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
@@ -33,6 +35,16 @@ protected function rowsWithFallback(SocialAccount $account, string $url, array $
return $this->rows($account, $url, [...$query, 'fields' => $fallbackFields]);
}
+ /**
+ * @param array $row
+ */
+ protected function timestamp(array $row, string $key): ?CarbonInterface
+ {
+ $value = data_get($row, $key);
+
+ return $value ? Carbon::parse($value) : null;
+ }
+
/**
* @param array $query
* @return array>
From 7a89de4d03317fd28f79855d615265593adf8349 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 12:41:02 -0300
Subject: [PATCH 097/114] Let the sync decide which account changes concern it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/Observers/SocialAccountObserver.php | 4 +---
app/Services/Repurpose/RepurposeAccountSync.php | 7 +++++++
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/app/Observers/SocialAccountObserver.php b/app/Observers/SocialAccountObserver.php
index e51653d39..df790ecfe 100644
--- a/app/Observers/SocialAccountObserver.php
+++ b/app/Observers/SocialAccountObserver.php
@@ -51,9 +51,7 @@ public function deleting(SocialAccount $socialAccount): void
public function updated(SocialAccount $socialAccount): void
{
- if ($socialAccount->wasChanged(['status', 'is_active', 'platform'])) {
- app(RepurposeAccountSync::class)->accountChanged($socialAccount);
- }
+ app(RepurposeAccountSync::class)->accountChanged($socialAccount);
if (! $socialAccount->wasChanged('status')) {
return;
diff --git a/app/Services/Repurpose/RepurposeAccountSync.php b/app/Services/Repurpose/RepurposeAccountSync.php
index daa6aeb83..8d82fb8c6 100644
--- a/app/Services/Repurpose/RepurposeAccountSync.php
+++ b/app/Services/Repurpose/RepurposeAccountSync.php
@@ -21,6 +21,9 @@
class RepurposeAccountSync
{
+ /** @var array */
+ private const WATCHED_ATTRIBUTES = ['status', 'is_active', 'platform'];
+
public function accountRemoved(SocialAccount $account): void
{
$this->guard(function () use ($account): void {
@@ -34,6 +37,10 @@ public function accountRemoved(SocialAccount $account): void
public function accountChanged(SocialAccount $account): void
{
+ if (! $account->wasChanged(self::WATCHED_ATTRIBUTES)) {
+ return;
+ }
+
$this->guard(function () use ($account): void {
if ($account->wasChanged('platform')) {
$this->realignDestinations($account);
From 1049f88457572a457f1143db2396dc0244fdffb6 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 12:42:38 -0300
Subject: [PATCH 098/114] Name the reel subtraction in the Facebook fetcher
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.
---
.../Repurpose/FacebookSourceFetcher.php | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/app/Services/Repurpose/FacebookSourceFetcher.php b/app/Services/Repurpose/FacebookSourceFetcher.php
index 9590523ec..b859381b7 100644
--- a/app/Services/Repurpose/FacebookSourceFetcher.php
+++ b/app/Services/Repurpose/FacebookSourceFetcher.php
@@ -32,20 +32,29 @@ public function fetch(SocialAccount $account, ?CarbonInterface $since, array $fo
: [];
$videos = $wantsVideos
- ? $this->videos($account, 'videos', $since, SourceFormat::Video)
+ ? $this->withoutReels($this->videos($account, 'videos', $since, SourceFormat::Video), $reels)
: [];
$stories = in_array(SourceFormat::Story, $formats, true)
? $this->stories($account, $since)
: [];
+ return [...($wantsReels ? $reels : []), ...$videos, ...$stories];
+ }
+
+ /**
+ * @param array $videos
+ * @param array $reels
+ * @return array
+ */
+ private function withoutReels(array $videos, array $reels): array
+ {
$reelIds = array_map(fn (SourceMedia $media): string => $media->id, $reels);
- $videos = array_values(array_filter(
+
+ return array_values(array_filter(
$videos,
fn (SourceMedia $media): bool => ! in_array($media->id, $reelIds, true),
));
-
- return [...($wantsReels ? $reels : []), ...$videos, ...$stories];
}
/**
From 42d872693154e13e5fd8b20386f0de691e57a3a3 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 13:08:33 -0300
Subject: [PATCH 099/114] Let a repurpose answer which accounts it depends on
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../Controllers/Auth/SocialController.php | 4 +--
app/Models/Repurpose.php | 12 +++++++
.../Repurpose/RepurposeAccountSync.php | 3 +-
.../Feature/Repurpose/RepurposeModelTest.php | 36 +++++++++++++++++++
4 files changed, 50 insertions(+), 5 deletions(-)
diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php
index d668a2676..b63de0409 100644
--- a/app/Http/Controllers/Auth/SocialController.php
+++ b/app/Http/Controllers/Auth/SocialController.php
@@ -307,9 +307,7 @@ private function repurposeStatesFor(SocialAccount $account): Collection
return Repurpose::query()
->where('workspace_id', $account->workspace_id)
->get()
- ->filter(fn (Repurpose $repurpose): bool => $repurpose->source_social_account_id === $account->id
- || collect($repurpose->destinations)
- ->contains(fn (array $destination): bool => data_get($destination, 'social_account_id') === $account->id))
+ ->filter(fn (Repurpose $repurpose): bool => $repurpose->dependsOn($account))
->pluck('status', 'id');
}
diff --git a/app/Models/Repurpose.php b/app/Models/Repurpose.php
index 49ce0b5ba..c82b9e282 100644
--- a/app/Models/Repurpose.php
+++ b/app/Models/Repurpose.php
@@ -70,4 +70,16 @@ public function items(): HasMany
{
return $this->hasMany(RepurposeItem::class);
}
+
+ public function hasDestination(string $socialAccountId): bool
+ {
+ return collect($this->destinations)
+ ->contains(fn (array $destination): bool => data_get($destination, 'social_account_id') === $socialAccountId);
+ }
+
+ public function dependsOn(SocialAccount $account): bool
+ {
+ return $this->source_social_account_id === $account->id
+ || $this->hasDestination($account->id);
+ }
}
diff --git a/app/Services/Repurpose/RepurposeAccountSync.php b/app/Services/Repurpose/RepurposeAccountSync.php
index 8d82fb8c6..50c771bc9 100644
--- a/app/Services/Repurpose/RepurposeAccountSync.php
+++ b/app/Services/Repurpose/RepurposeAccountSync.php
@@ -148,8 +148,7 @@ private function destinedFor(SocialAccount $account): SupportCollection
return Repurpose::query()
->where('workspace_id', $account->workspace_id)
->get()
- ->filter(fn (Repurpose $repurpose): bool => collect($repurpose->destinations)
- ->contains(fn (array $destination): bool => data_get($destination, 'social_account_id') === $account->id))
+ ->filter(fn (Repurpose $repurpose): bool => $repurpose->hasDestination($account->id))
->values();
}
diff --git a/tests/Feature/Repurpose/RepurposeModelTest.php b/tests/Feature/Repurpose/RepurposeModelTest.php
index 93a965c35..bf16bb955 100644
--- a/tests/Feature/Repurpose/RepurposeModelTest.php
+++ b/tests/Feature/Repurpose/RepurposeModelTest.php
@@ -2,9 +2,11 @@
declare(strict_types=1);
+use App\Enums\PostPlatform\ContentType;
use App\Enums\Repurpose\ItemStatus;
use App\Enums\Repurpose\SourceFormat;
use App\Enums\Repurpose\Status;
+use App\Enums\SocialAccount\Platform;
use App\Models\Repurpose;
use App\Models\RepurposeItem;
use App\Models\SocialAccount;
@@ -75,3 +77,37 @@
'source_format' => SourceFormat::Reel,
]))->toThrow(QueryException::class);
});
+
+test('a repurpose knows which accounts it depends on', function () {
+ $workspace = Workspace::factory()->create();
+ $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]);
+ $destination = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::TikTok]);
+ $stranger = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Mastodon]);
+
+ $repurpose = Repurpose::factory()->for($workspace)->create([
+ 'source_social_account_id' => $source->id,
+ 'destinations' => [
+ ['social_account_id' => $destination->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => []],
+ ],
+ ]);
+
+ expect($repurpose->dependsOn($source))->toBeTrue()
+ ->and($repurpose->dependsOn($destination))->toBeTrue()
+ ->and($repurpose->dependsOn($stranger))->toBeFalse()
+ ->and($repurpose->hasDestination($destination->id))->toBeTrue()
+ ->and($repurpose->hasDestination($source->id))->toBeFalse();
+});
+
+test('a repurpose with no destinations depends only on its source', function () {
+ $workspace = Workspace::factory()->create();
+ $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]);
+ $other = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::TikTok]);
+
+ $repurpose = Repurpose::factory()->for($workspace)->create([
+ 'source_social_account_id' => $source->id,
+ 'destinations' => [],
+ ]);
+
+ expect($repurpose->dependsOn($source))->toBeTrue()
+ ->and($repurpose->dependsOn($other))->toBeFalse();
+});
From ac5ea66ccbdb978b4be992c40a21c0fcf6336371 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 13:16:09 -0300
Subject: [PATCH 100/114] Move the DTOs into app/Dto
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/{DataTransferObjects => Dto}/MediaItem.php | 2 +-
app/{Services/Repurpose => Dto}/SourceMedia.php | 2 +-
app/Jobs/Repurpose/PollRepurposeSource.php | 2 +-
app/Models/Post.php | 2 +-
app/Services/Repurpose/FacebookSourceFetcher.php | 1 +
app/Services/Repurpose/InstagramSourceFetcher.php | 1 +
app/Services/Repurpose/SourceFetcher.php | 1 +
app/Services/Social/Discord/DiscordPublisher.php | 2 +-
app/Services/Social/Telegram/TelegramMediaType.php | 2 +-
app/Services/Social/Telegram/TelegramPublisher.php | 2 +-
app/Services/Social/TikTokPublisher.php | 2 +-
app/Services/Social/XPublisher.php | 2 +-
app/Services/WebhookService.php | 2 +-
tests/Unit/{DataTransferObjects => Dto}/MediaItemTest.php | 2 +-
tests/Unit/MediaItemAltTextTest.php | 2 +-
15 files changed, 15 insertions(+), 12 deletions(-)
rename app/{DataTransferObjects => Dto}/MediaItem.php (99%)
rename app/{Services/Repurpose => Dto}/SourceMedia.php (94%)
rename tests/Unit/{DataTransferObjects => Dto}/MediaItemTest.php (98%)
diff --git a/app/DataTransferObjects/MediaItem.php b/app/Dto/MediaItem.php
similarity index 99%
rename from app/DataTransferObjects/MediaItem.php
rename to app/Dto/MediaItem.php
index 44beae7dd..dbb2664c2 100644
--- a/app/DataTransferObjects/MediaItem.php
+++ b/app/Dto/MediaItem.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace App\DataTransferObjects;
+namespace App\Dto;
use App\Enums\Media\Source;
use App\Enums\Media\Type;
diff --git a/app/Services/Repurpose/SourceMedia.php b/app/Dto/SourceMedia.php
similarity index 94%
rename from app/Services/Repurpose/SourceMedia.php
rename to app/Dto/SourceMedia.php
index ed2e65e1e..f77a55f63 100644
--- a/app/Services/Repurpose/SourceMedia.php
+++ b/app/Dto/SourceMedia.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace App\Services\Repurpose;
+namespace App\Dto;
use App\Enums\Repurpose\SourceFormat;
use Carbon\CarbonInterface;
diff --git a/app/Jobs/Repurpose/PollRepurposeSource.php b/app/Jobs/Repurpose/PollRepurposeSource.php
index 7af0ae7a9..2b55695d4 100644
--- a/app/Jobs/Repurpose/PollRepurposeSource.php
+++ b/app/Jobs/Repurpose/PollRepurposeSource.php
@@ -4,6 +4,7 @@
namespace App\Jobs\Repurpose;
+use App\Dto\SourceMedia;
use App\Enums\Repurpose\ItemReason;
use App\Enums\Repurpose\ItemStatus;
use App\Enums\Repurpose\SourceFormat;
@@ -14,7 +15,6 @@
use App\Models\RepurposeItem;
use App\Models\SocialAccount;
use App\Services\Repurpose\SourceFetcherFactory;
-use App\Services\Repurpose\SourceMedia;
use App\Services\Social\TokenRedactor;
use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
diff --git a/app/Models/Post.php b/app/Models/Post.php
index ebe88db72..cc88d34b0 100644
--- a/app/Models/Post.php
+++ b/app/Models/Post.php
@@ -4,7 +4,7 @@
namespace App\Models;
-use App\DataTransferObjects\MediaItem;
+use App\Dto\MediaItem;
use App\Enums\Media\Type;
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
diff --git a/app/Services/Repurpose/FacebookSourceFetcher.php b/app/Services/Repurpose/FacebookSourceFetcher.php
index b859381b7..b69381e51 100644
--- a/app/Services/Repurpose/FacebookSourceFetcher.php
+++ b/app/Services/Repurpose/FacebookSourceFetcher.php
@@ -4,6 +4,7 @@
namespace App\Services\Repurpose;
+use App\Dto\SourceMedia;
use App\Enums\Facebook\StoryMediaType;
use App\Enums\Facebook\StoryStatus;
use App\Enums\Repurpose\SourceFormat;
diff --git a/app/Services/Repurpose/InstagramSourceFetcher.php b/app/Services/Repurpose/InstagramSourceFetcher.php
index 21b3e8790..7a86f7f2c 100644
--- a/app/Services/Repurpose/InstagramSourceFetcher.php
+++ b/app/Services/Repurpose/InstagramSourceFetcher.php
@@ -4,6 +4,7 @@
namespace App\Services\Repurpose;
+use App\Dto\SourceMedia;
use App\Enums\Instagram\MediaProductType;
use App\Enums\Instagram\MediaType;
use App\Enums\Repurpose\SourceFormat;
diff --git a/app/Services/Repurpose/SourceFetcher.php b/app/Services/Repurpose/SourceFetcher.php
index 421df78c1..d2466f384 100644
--- a/app/Services/Repurpose/SourceFetcher.php
+++ b/app/Services/Repurpose/SourceFetcher.php
@@ -4,6 +4,7 @@
namespace App\Services\Repurpose;
+use App\Dto\SourceMedia;
use App\Enums\Repurpose\SourceFormat;
use App\Models\SocialAccount;
use Carbon\CarbonInterface;
diff --git a/app/Services/Social/Discord/DiscordPublisher.php b/app/Services/Social/Discord/DiscordPublisher.php
index cb58c7b3f..44b6a0150 100644
--- a/app/Services/Social/Discord/DiscordPublisher.php
+++ b/app/Services/Social/Discord/DiscordPublisher.php
@@ -4,7 +4,7 @@
namespace App\Services\Social\Discord;
-use App\DataTransferObjects\MediaItem;
+use App\Dto\MediaItem;
use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\DiscordPublishException;
diff --git a/app/Services/Social/Telegram/TelegramMediaType.php b/app/Services/Social/Telegram/TelegramMediaType.php
index d7b881b32..1c8f6d250 100644
--- a/app/Services/Social/Telegram/TelegramMediaType.php
+++ b/app/Services/Social/Telegram/TelegramMediaType.php
@@ -4,7 +4,7 @@
namespace App\Services\Social\Telegram;
-use App\DataTransferObjects\MediaItem;
+use App\Dto\MediaItem;
/**
* Telegram's media kinds, as used both in the `sendMediaGroup` `type` field and
diff --git a/app/Services/Social/Telegram/TelegramPublisher.php b/app/Services/Social/Telegram/TelegramPublisher.php
index 7a589da19..7692f22cd 100644
--- a/app/Services/Social/Telegram/TelegramPublisher.php
+++ b/app/Services/Social/Telegram/TelegramPublisher.php
@@ -4,7 +4,7 @@
namespace App\Services\Social\Telegram;
-use App\DataTransferObjects\MediaItem;
+use App\Dto\MediaItem;
use App\Exceptions\Social\TelegramPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php
index 645ff67d4..f5e9eabf5 100644
--- a/app/Services/Social/TikTokPublisher.php
+++ b/app/Services/Social/TikTokPublisher.php
@@ -4,7 +4,7 @@
namespace App\Services\Social;
-use App\DataTransferObjects\MediaItem;
+use App\Dto\MediaItem;
use App\Enums\SocialAccount\Platform;
use App\Enums\TikTok\PublishStatus;
use App\Exceptions\PlatformUnavailableException;
diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php
index ae4aee94c..6ef2c006a 100644
--- a/app/Services/Social/XPublisher.php
+++ b/app/Services/Social/XPublisher.php
@@ -4,7 +4,7 @@
namespace App\Services\Social;
-use App\DataTransferObjects\MediaItem;
+use App\Dto\MediaItem;
use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php
index 453eba0a1..2a2e3e3b8 100644
--- a/app/Services/WebhookService.php
+++ b/app/Services/WebhookService.php
@@ -4,7 +4,7 @@
namespace App\Services;
-use App\DataTransferObjects\MediaItem;
+use App\Dto\MediaItem;
use App\Enums\Media\Type;
use App\Enums\Webhook\EventType as WebhookEvent;
use App\Jobs\DispatchWebhook;
diff --git a/tests/Unit/DataTransferObjects/MediaItemTest.php b/tests/Unit/Dto/MediaItemTest.php
similarity index 98%
rename from tests/Unit/DataTransferObjects/MediaItemTest.php
rename to tests/Unit/Dto/MediaItemTest.php
index ee6fdfac7..63d5e1e20 100644
--- a/tests/Unit/DataTransferObjects/MediaItemTest.php
+++ b/tests/Unit/Dto/MediaItemTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-use App\DataTransferObjects\MediaItem;
+use App\Dto\MediaItem;
test('fromArray backfills the mime type from the path extension when missing', function () {
expect(MediaItem::fromArray(['path' => 'a/b/photo.JPG'])->mime_type)->toBe('image/jpeg');
diff --git a/tests/Unit/MediaItemAltTextTest.php b/tests/Unit/MediaItemAltTextTest.php
index d9e20cc58..d194ed806 100644
--- a/tests/Unit/MediaItemAltTextTest.php
+++ b/tests/Unit/MediaItemAltTextTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-use App\DataTransferObjects\MediaItem;
+use App\Dto\MediaItem;
use App\Enums\SocialAccount\Platform;
test('altText returns the trimmed meta alt_text', function () {
From 5afed239fb1be8301d16eac59ecf425c3569c7ae Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 13:19:36 -0300
Subject: [PATCH 101/114] Name the watermark check after what it can actually
prove
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/Dto/SourceMedia.php | 8 ++++----
app/Jobs/Repurpose/PollRepurposeSource.php | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/app/Dto/SourceMedia.php b/app/Dto/SourceMedia.php
index f77a55f63..fc2cb1e94 100644
--- a/app/Dto/SourceMedia.php
+++ b/app/Dto/SourceMedia.php
@@ -18,10 +18,10 @@ public function __construct(
public ?CarbonInterface $createdAt,
) {}
- public function isNewerThan(?CarbonInterface $watermark): bool
+ public function predates(?CarbonInterface $watermark): bool
{
- return $watermark === null
- || $this->createdAt === null
- || $this->createdAt->greaterThan($watermark);
+ return $watermark !== null
+ && $this->createdAt !== null
+ && $this->createdAt->lessThanOrEqualTo($watermark);
}
}
diff --git a/app/Jobs/Repurpose/PollRepurposeSource.php b/app/Jobs/Repurpose/PollRepurposeSource.php
index 2b55695d4..9ae1524b4 100644
--- a/app/Jobs/Repurpose/PollRepurposeSource.php
+++ b/app/Jobs/Repurpose/PollRepurposeSource.php
@@ -118,7 +118,7 @@ private function queueNewMedia(Repurpose $repurpose, array $media, array $publis
{
collect($media)
->filter(fn (SourceMedia $entry): bool => $entry->format === $repurpose->source_format)
- ->filter(fn (SourceMedia $entry): bool => $entry->isNewerThan($repurpose->activated_at))
+ ->reject(fn (SourceMedia $entry): bool => $entry->predates($repurpose->activated_at))
->each(fn (SourceMedia $entry) => $this->queue($repurpose, $entry, $publishedByUs));
}
From d0d13f2372af034927bcb09698bbfd3a0392a100 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 13:22:18 -0300
Subject: [PATCH 102/114] Search by words so the caption never needs a second
cut
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/Services/Repurpose/CaptionAdapter.php | 39 +++++++++++--------
.../Feature/Repurpose/CaptionAdapterTest.php | 11 ++++++
2 files changed, 34 insertions(+), 16 deletions(-)
diff --git a/app/Services/Repurpose/CaptionAdapter.php b/app/Services/Repurpose/CaptionAdapter.php
index 44c60d3d5..a5ebda645 100644
--- a/app/Services/Repurpose/CaptionAdapter.php
+++ b/app/Services/Repurpose/CaptionAdapter.php
@@ -12,7 +12,6 @@
use App\Services\Social\ContentSanitizer;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Str;
use Throwable;
class CaptionAdapter
@@ -82,35 +81,43 @@ private function shorten(Workspace $workspace, ?User $user, string $caption, Pla
private function truncate(string $caption, Platform $platform): string
{
- $longest = $this->longestFittingPrefix($caption, $platform);
- $atBoundary = $this->cutAtWord($longest);
+ $words = explode(' ', $caption);
- return $this->fits($atBoundary, $platform) ? $atBoundary : $longest;
+ $whole = $this->longestFitting(
+ count($words),
+ fn (int $take): string => rtrim(implode(' ', array_slice($words, 0, $take))),
+ $platform,
+ );
+
+ if ($whole !== '') {
+ return $whole;
+ }
+
+ return $this->longestFitting(
+ mb_strlen($caption),
+ fn (int $take): string => rtrim(mb_substr($caption, 0, $take)),
+ $platform,
+ );
}
- private function longestFittingPrefix(string $caption, Platform $platform): string
+ /**
+ * @param callable(int): string $take
+ */
+ private function longestFitting(int $most, callable $take, Platform $platform): string
{
$low = 0;
- $high = mb_strlen($caption);
+ $high = $most;
while ($low < $high) {
$middle = intdiv($low + $high + 1, 2);
- if ($this->fits(mb_substr($caption, 0, $middle), $platform)) {
+ if ($this->fits($take($middle), $platform)) {
$low = $middle;
} else {
$high = $middle - 1;
}
}
- return mb_substr($caption, 0, $low);
- }
-
- private function cutAtWord(string $caption): string
- {
- $trimmed = rtrim($caption);
- $atBoundary = rtrim(Str::beforeLast($trimmed, ' '));
-
- return $atBoundary !== '' ? $atBoundary : $trimmed;
+ return $take($low);
}
}
diff --git a/tests/Feature/Repurpose/CaptionAdapterTest.php b/tests/Feature/Repurpose/CaptionAdapterTest.php
index 1c73cbe69..93fd39ee0 100644
--- a/tests/Feature/Repurpose/CaptionAdapterTest.php
+++ b/tests/Feature/Repurpose/CaptionAdapterTest.php
@@ -146,3 +146,14 @@
expect($result)->not->toBe('')
->and(Platform::YouTube->contentOverflow($result))->toBe(0);
});
+
+test('a single word longer than the limit is cut mid-word rather than emptied', function () {
+ $workspace = Workspace::factory()->create();
+ $caption = str_repeat('a', 300);
+
+ $adapted = app(CaptionAdapter::class)->adapt($workspace, null, $caption, Platform::YouTube);
+
+ expect($adapted)->not->toBe('')
+ ->and(mb_strlen($adapted))->toBeLessThan(300)
+ ->and(Platform::YouTube->contentOverflow($adapted))->toBe(0);
+});
From e2d8ab08e63b2ede54254f79b3f1230c8190f00a Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 13:34:13 -0300
Subject: [PATCH 103/114] Drop a word until the caption fits, instead of binary
searching for it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/Services/Repurpose/CaptionAdapter.php | 39 ++++++-----------------
1 file changed, 9 insertions(+), 30 deletions(-)
diff --git a/app/Services/Repurpose/CaptionAdapter.php b/app/Services/Repurpose/CaptionAdapter.php
index a5ebda645..2d93884a2 100644
--- a/app/Services/Repurpose/CaptionAdapter.php
+++ b/app/Services/Repurpose/CaptionAdapter.php
@@ -83,41 +83,20 @@ private function truncate(string $caption, Platform $platform): string
{
$words = explode(' ', $caption);
- $whole = $this->longestFitting(
- count($words),
- fn (int $take): string => rtrim(implode(' ', array_slice($words, 0, $take))),
- $platform,
- );
-
- if ($whole !== '') {
- return $whole;
+ while ($words !== [] && ! $this->fits(rtrim(implode(' ', $words)), $platform)) {
+ array_pop($words);
}
- return $this->longestFitting(
- mb_strlen($caption),
- fn (int $take): string => rtrim(mb_substr($caption, 0, $take)),
- $platform,
- );
- }
-
- /**
- * @param callable(int): string $take
- */
- private function longestFitting(int $most, callable $take, Platform $platform): string
- {
- $low = 0;
- $high = $most;
+ if ($words !== []) {
+ return rtrim(implode(' ', $words));
+ }
- while ($low < $high) {
- $middle = intdiv($low + $high + 1, 2);
+ $letters = mb_substr($caption, 0, $platform->maxContentLength());
- if ($this->fits($take($middle), $platform)) {
- $low = $middle;
- } else {
- $high = $middle - 1;
- }
+ while ($letters !== '' && ! $this->fits($letters, $platform)) {
+ $letters = mb_substr($letters, 0, -1);
}
- return $take($low);
+ return $letters;
}
}
From a306c97221f3e3749d98941872e124f2f2a02bfc Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 13:51:18 -0300
Subject: [PATCH 104/114] Reach for the framework's helpers in the caption
adapter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
app/Services/Repurpose/CaptionAdapter.php | 76 +++++++------------
.../Feature/Repurpose/CaptionAdapterTest.php | 19 +++++
2 files changed, 48 insertions(+), 47 deletions(-)
diff --git a/app/Services/Repurpose/CaptionAdapter.php b/app/Services/Repurpose/CaptionAdapter.php
index 2d93884a2..8bf5a5e91 100644
--- a/app/Services/Repurpose/CaptionAdapter.php
+++ b/app/Services/Repurpose/CaptionAdapter.php
@@ -11,8 +11,7 @@
use App\Services\Ai\RecordAiUsage;
use App\Services\Social\ContentSanitizer;
use Illuminate\Support\Facades\Gate;
-use Illuminate\Support\Facades\Log;
-use Throwable;
+use Illuminate\Support\Str;
class CaptionAdapter
{
@@ -44,59 +43,42 @@ private function shorten(Workspace $workspace, ?User $user, string $caption, Pla
return null;
}
- try {
- $result = (new PostContentShortener(
- workspace: $workspace,
- platformLabel: $platform->label(),
- limit: $platform->maxContentLength(),
- ))->prompt($caption);
-
- RecordAiUsage::recordText(
- workspace: $workspace,
- promptTokens: $result->usage->promptTokens,
- completionTokens: $result->usage->completionTokens,
- provider: (string) $result->meta->provider,
- model: (string) $result->meta->model,
- userId: $user->id,
- metadata: ['agent' => 'post_shortener'],
- );
- } catch (Throwable $exception) {
- Log::warning('Caption shortening failed, falling back to truncation', [
- 'workspace_id' => $workspace->id,
- 'platform' => $platform->value,
- 'message' => $exception->getMessage(),
- ]);
+ $shortened = rescue(fn (): string => $this->ask($workspace, $user, $caption, $platform));
- return null;
- }
-
- $shortened = trim((string) $result->text);
-
- if ($shortened === '' || ! $this->fits($shortened, $platform)) {
- return null;
- }
+ return filled($shortened) && $this->fits($shortened, $platform) ? $shortened : null;
+ }
- return $shortened;
+ private function ask(Workspace $workspace, User $user, string $caption, Platform $platform): string
+ {
+ $result = (new PostContentShortener(
+ workspace: $workspace,
+ platformLabel: $platform->label(),
+ limit: $platform->maxContentLength(),
+ ))->prompt($caption);
+
+ RecordAiUsage::recordText(
+ workspace: $workspace,
+ promptTokens: $result->usage->promptTokens,
+ completionTokens: $result->usage->completionTokens,
+ provider: (string) $result->meta->provider,
+ model: (string) $result->meta->model,
+ userId: $user->id,
+ metadata: ['agent' => 'post_shortener'],
+ );
+
+ return trim((string) $result->text);
}
private function truncate(string $caption, Platform $platform): string
{
- $words = explode(' ', $caption);
-
- while ($words !== [] && ! $this->fits(rtrim(implode(' ', $words)), $platform)) {
- array_pop($words);
- }
-
- if ($words !== []) {
- return rtrim(implode(' ', $words));
- }
-
- $letters = mb_substr($caption, 0, $platform->maxContentLength());
+ $candidate = $caption;
- while ($letters !== '' && ! $this->fits($letters, $platform)) {
- $letters = mb_substr($letters, 0, -1);
+ while (! $this->fits($candidate, $platform) && str_contains($candidate, ' ')) {
+ $candidate = rtrim(Str::beforeLast($candidate, ' '));
}
- return $letters;
+ return $this->fits($candidate, $platform)
+ ? $candidate
+ : Str::limit($caption, $platform->maxContentLength(), '');
}
}
diff --git a/tests/Feature/Repurpose/CaptionAdapterTest.php b/tests/Feature/Repurpose/CaptionAdapterTest.php
index 93fd39ee0..ca38e1467 100644
--- a/tests/Feature/Repurpose/CaptionAdapterTest.php
+++ b/tests/Feature/Repurpose/CaptionAdapterTest.php
@@ -157,3 +157,22 @@
->and(mb_strlen($adapted))->toBeLessThan(300)
->and(Platform::YouTube->contentOverflow($adapted))->toBe(0);
});
+
+test('a caption of one long word without spaces terminates instead of looping', function () {
+ $workspace = Workspace::factory()->create();
+
+ $adapted = app(CaptionAdapter::class)->adapt($workspace, null, str_repeat('x', 500), Platform::X);
+
+ expect(Platform::X->contentOverflow($adapted))->toBe(0)
+ ->and($adapted)->not->toBe('');
+});
+
+test('newlines and repeated spaces survive truncation', function () {
+ $workspace = Workspace::factory()->create();
+ $caption = "First line\n\nSecond line with gaps ".str_repeat('word ', 100);
+
+ $adapted = app(CaptionAdapter::class)->adapt($workspace, null, $caption, Platform::X);
+
+ expect($adapted)->toStartWith("First line\n\nSecond line with gaps")
+ ->and(Platform::X->contentOverflow($adapted))->toBe(0);
+});
From ccad6aae0629268e674ffbc69c698d0c248c577a Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 14:08:22 -0300
Subject: [PATCH 105/114] Validate destination meta when the repurpose is
saved, not only when activated
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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. so the
error lands on the control that is missing rather than on the form.
---
.../Api/Repurpose/StoreRepurposeRequest.php | 6 ++
.../Api/Repurpose/UpdateRepurposeRequest.php | 6 ++
.../App/Repurpose/UpdateRepurposeRequest.php | 6 ++
.../Tools/Repurpose/CreateRepurposeTool.php | 6 ++
.../Tools/Repurpose/UpdateRepurposeTool.php | 6 ++
.../Repurpose/DestinationMetaRules.php | 49 ++++++++++++++++
tests/Feature/Mcp/RepurposeToolTest.php | 22 ++++++++
tests/Feature/Repurpose/AccountHealthTest.php | 56 +++++++++++++++++++
8 files changed, 157 insertions(+)
diff --git a/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php b/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php
index a7cca9c84..eec5a2897 100644
--- a/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php
+++ b/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php
@@ -117,6 +117,12 @@ public function withValidator(Validator $validator): void
(array) $this->input('destinations', []),
$sourceAccountId,
);
+
+ DestinationMetaRules::addRequiredErrors(
+ $validator,
+ (array) $this->input('destinations', []),
+ $this->workspaceId(),
+ );
});
}
}
diff --git a/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php b/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php
index 00be26aea..30df0858c 100644
--- a/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php
+++ b/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php
@@ -123,6 +123,12 @@ public function withValidator(Validator $validator): void
(array) $this->input('destinations', []),
$sourceAccountId,
);
+
+ DestinationMetaRules::addRequiredErrors(
+ $validator,
+ (array) $this->input('destinations', []),
+ $this->workspaceId(),
+ );
});
}
}
diff --git a/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php b/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php
index c4b606210..ec323c455 100644
--- a/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php
+++ b/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php
@@ -123,6 +123,12 @@ public function withValidator(Validator $validator): void
(array) $this->input('destinations', []),
$sourceAccountId,
);
+
+ DestinationMetaRules::addRequiredErrors(
+ $validator,
+ (array) $this->input('destinations', []),
+ $this->workspaceId(),
+ );
});
}
}
diff --git a/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php b/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php
index 0763ff1fb..86b809c2e 100644
--- a/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php
+++ b/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php
@@ -10,6 +10,7 @@
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Mcp\Requests\Repurpose\CreateRepurposeRequest;
use App\Models\Workspace;
+use App\Support\Repurpose\DestinationMetaRules;
use App\Support\Repurpose\SourceIsFree;
use App\Support\Repurpose\SourceIsNotADestination;
use Illuminate\Contracts\JsonSchema\JsonSchema;
@@ -46,6 +47,11 @@ public function handle(Request $request): Response|ResponseFactory
data_get($validated, 'source_social_account_id'),
);
+ DestinationMetaRules::assertRequired(
+ (array) data_get($validated, 'destinations', []),
+ $workspace->id,
+ );
+
try {
$repurpose = CreateRepurpose::execute($workspace, $request->user(), $validated);
} catch (ValidationException $e) {
diff --git a/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php b/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php
index fe672c9ed..e9848cb69 100644
--- a/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php
+++ b/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php
@@ -13,6 +13,7 @@
use App\Mcp\Requests\Repurpose\UpdateRepurposeRequest;
use App\Models\Repurpose;
use App\Models\Workspace;
+use App\Support\Repurpose\DestinationMetaRules;
use App\Support\Repurpose\SourceIsFree;
use App\Support\Repurpose\SourceIsNotADestination;
use Illuminate\Contracts\JsonSchema\JsonSchema;
@@ -56,6 +57,11 @@ public function handle(Request $request): Response|ResponseFactory
data_get($validated, 'source_social_account_id', $repurpose->source_social_account_id),
);
+ DestinationMetaRules::assertRequired(
+ (array) data_get($validated, 'destinations', []),
+ $workspace->id,
+ );
+
return Response::structured(
(new RepurposeResource(UpdateRepurpose::execute($repurpose, $validated)))->resolve(),
);
diff --git a/app/Support/Repurpose/DestinationMetaRules.php b/app/Support/Repurpose/DestinationMetaRules.php
index 2e4269853..4022b55e5 100644
--- a/app/Support/Repurpose/DestinationMetaRules.php
+++ b/app/Support/Repurpose/DestinationMetaRules.php
@@ -4,8 +4,12 @@
namespace App\Support\Repurpose;
+use App\Models\SocialAccount;
use App\Support\PostPlatformMetaRules;
+use Illuminate\Support\Facades\Validator as ValidatorFacade;
use Illuminate\Support\Str;
+use Illuminate\Validation\ValidationException;
+use Illuminate\Validation\Validator;
class DestinationMetaRules
{
@@ -33,6 +37,51 @@ public static function attributes(): array
return self::reKey(PostPlatformMetaRules::attributes());
}
+ /**
+ * A repurpose publishes without anyone reviewing the post first, so a
+ * destination missing the meta its network needs can only fail later, in a
+ * queued job. Checked on save, the way the post editor checks it before
+ * scheduling.
+ *
+ * @param array $destinations
+ */
+ public static function addRequiredErrors(Validator $validator, array $destinations, ?string $workspaceId): void
+ {
+ $platforms = SocialAccount::query()
+ ->where('workspace_id', $workspaceId)
+ ->findMany(array_map(
+ fn (mixed $destination): mixed => data_get($destination, 'social_account_id'),
+ $destinations,
+ ))
+ ->pluck('platform', 'id');
+
+ foreach ($destinations as $index => $destination) {
+ $violation = PostPlatformMetaRules::requiredMetaViolation(
+ $platforms->get(data_get($destination, 'social_account_id')),
+ data_get($destination, 'meta'),
+ );
+
+ if ($violation !== null) {
+ [$field, $message] = $violation;
+ $validator->errors()->add("destinations.{$index}.meta.{$field}", $message);
+ }
+ }
+ }
+
+ /**
+ * @param array $destinations
+ */
+ public static function assertRequired(array $destinations, ?string $workspaceId): void
+ {
+ $validator = ValidatorFacade::make([], []);
+
+ self::addRequiredErrors($validator, $destinations, $workspaceId);
+
+ if ($validator->errors()->isNotEmpty()) {
+ throw new ValidationException($validator);
+ }
+ }
+
/**
* @param array $entries
* @return array
diff --git a/tests/Feature/Mcp/RepurposeToolTest.php b/tests/Feature/Mcp/RepurposeToolTest.php
index 3514c1f54..a99355ff2 100644
--- a/tests/Feature/Mcp/RepurposeToolTest.php
+++ b/tests/Feature/Mcp/RepurposeToolTest.php
@@ -347,3 +347,25 @@ function tiktokDestinationForMcp(SocialAccount $account): array
expect($repurpose->fresh()->destinations)->toHaveCount(1);
});
+
+test('the update tool refuses a pinterest destination without a board', function () {
+ $pinterest = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::Pinterest]);
+
+ $repurpose = Repurpose::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'source_social_account_id' => $this->source->id,
+ ]);
+
+ TryPostServer::actingAs($this->user)
+ ->tool(UpdateRepurposeTool::class, [
+ 'repurpose_id' => $repurpose->id,
+ 'destinations' => [[
+ 'social_account_id' => $pinterest->id,
+ 'content_type' => ContentType::PinterestVideoPin->value,
+ 'meta' => [],
+ ]],
+ ])
+ ->assertHasErrors();
+
+ expect($repurpose->fresh()->destinations)->toBe([]);
+});
diff --git a/tests/Feature/Repurpose/AccountHealthTest.php b/tests/Feature/Repurpose/AccountHealthTest.php
index 8ac3b1af7..c3665f551 100644
--- a/tests/Feature/Repurpose/AccountHealthTest.php
+++ b/tests/Feature/Repurpose/AccountHealthTest.php
@@ -732,3 +732,59 @@ function healthDestination(Workspace $workspace): array
expect($repurpose->fresh()->activated_at)->toBeNull();
});
+
+test('a pinterest destination cannot be saved without a board', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+
+ $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]);
+ $pinterest = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Pinterest]);
+
+ $repurpose = Repurpose::factory()->for($workspace)->create([
+ 'source_social_account_id' => $source->id,
+ 'status' => Status::Draft,
+ ]);
+
+ $this->actingAs($user)
+ ->put(route('app.repurposes.update', $repurpose), [
+ 'destinations' => [[
+ 'social_account_id' => $pinterest->id,
+ 'content_type' => ContentType::PinterestVideoPin->value,
+ 'meta' => [],
+ ]],
+ ])
+ ->assertSessionHasErrors('destinations.0.meta.board_id');
+
+ expect($repurpose->fresh()->destinations)->toBe([]);
+});
+
+test('a tiktok destination cannot be saved without a privacy level', function () {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create([
+ 'account_id' => $user->account_id,
+ 'user_id' => $user->id,
+ ]);
+ $user->update(['current_workspace_id' => $workspace->id]);
+
+ $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]);
+ $tiktok = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::TikTok]);
+
+ $repurpose = Repurpose::factory()->for($workspace)->create([
+ 'source_social_account_id' => $source->id,
+ 'status' => Status::Draft,
+ ]);
+
+ $this->actingAs($user)
+ ->put(route('app.repurposes.update', $repurpose), [
+ 'destinations' => [[
+ 'social_account_id' => $tiktok->id,
+ 'content_type' => ContentType::TikTokVideo->value,
+ 'meta' => [],
+ ]],
+ ])
+ ->assertSessionHasErrors('destinations.0.meta.privacy_level');
+});
From b6e230fd1892316aecdd084a47b70d078dbf7727 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 14:22:29 -0300
Subject: [PATCH 106/114] Remove the repurpose templates
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../Controllers/Api/RepurposeController.php | 6 +-
.../Controllers/App/RepurposeController.php | 2 -
app/Mcp/Servers/TryPostServer.php | 5 +-
...php => ListRepurposeSourceFormatsTool.php} | 6 +-
app/Support/Repurpose/Templates.php | 37 -----------
lang/ar/repurposes.php | 12 ----
lang/de/repurposes.php | 12 ----
lang/el/repurposes.php | 12 ----
lang/en/repurposes.php | 12 ----
lang/es/repurposes.php | 12 ----
lang/fr/repurposes.php | 12 ----
lang/it/repurposes.php | 12 ----
lang/ja/repurposes.php | 12 ----
lang/ko/repurposes.php | 12 ----
lang/nl/repurposes.php | 12 ----
lang/pl/repurposes.php | 12 ----
lang/pt-BR/repurposes.php | 12 ----
lang/ru/repurposes.php | 12 ----
lang/tr/repurposes.php | 12 ----
lang/uk/repurposes.php | 12 ----
lang/zh/repurposes.php | 12 ----
.../repurpose/CreateRepurposeDialog.vue | 12 +---
.../repurpose/RepurposeTemplateCard.vue | 42 ------------
resources/js/pages/repurposes/Index.vue | 23 ++-----
resources/js/types/repurpose.ts | 5 --
routes/api.php | 2 +-
tests/Browser/RepurposeTest.php | 54 ++++-----------
tests/Feature/Api/RepurposeApiTest.php | 16 ++---
tests/Feature/Mcp/RepurposeToolTest.php | 65 ++-----------------
.../Feature/Repurpose/TranslationKeysTest.php | 6 --
tests/Feature/Repurpose/WebTest.php | 3 +-
31 files changed, 44 insertions(+), 432 deletions(-)
rename app/Mcp/Tools/Repurpose/{ListRepurposeTemplatesTool.php => ListRepurposeSourceFormatsTool.php} (75%)
delete mode 100644 app/Support/Repurpose/Templates.php
delete mode 100644 resources/js/components/repurpose/RepurposeTemplateCard.vue
diff --git a/app/Http/Controllers/Api/RepurposeController.php b/app/Http/Controllers/Api/RepurposeController.php
index bc2057c8f..fb9165f9f 100644
--- a/app/Http/Controllers/Api/RepurposeController.php
+++ b/app/Http/Controllers/Api/RepurposeController.php
@@ -19,7 +19,6 @@
use App\Http\Resources\Api\RepurposeItemResource;
use App\Http\Resources\Api\RepurposeResource;
use App\Models\Repurpose;
-use App\Support\Repurpose\Templates;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -111,13 +110,12 @@ public function items(Request $request, Repurpose $repurpose): AnonymousResource
);
}
- public function templates(): JsonResponse
+ public function sourceFormats(): JsonResponse
{
$this->authorize('viewAny', Repurpose::class);
return response()->json([
- 'data' => Templates::all(),
- 'source_formats' => array_map(
+ 'data' => array_map(
fn (SourceFormat $format): array => ['value' => $format->value, 'label' => $format->label()],
SourceFormat::cases(),
),
diff --git a/app/Http/Controllers/App/RepurposeController.php b/app/Http/Controllers/App/RepurposeController.php
index 1fbc92e91..fc259e6c9 100644
--- a/app/Http/Controllers/App/RepurposeController.php
+++ b/app/Http/Controllers/App/RepurposeController.php
@@ -27,7 +27,6 @@
use App\Models\SocialAccount;
use App\Services\Repurpose\SourceFetcherFactory;
use App\Services\Social\TikTokCreatorInfo;
-use App\Support\Repurpose\Templates;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
@@ -45,7 +44,6 @@ public function index(Request $request): Response
return Inertia::render('repurposes/Index', [
'repurposes' => Inertia::scroll(fn () => RepurposeResource::collection(ListRepurposes::execute($workspace))),
- 'templates' => Templates::all(),
'sourceAccounts' => SocialAccountResource::collection($this->sourceAccounts($accounts)),
'destinationAccounts' => SocialAccountResource::collection($accounts),
]);
diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php
index 9808eee55..d20b18bdc 100644
--- a/app/Mcp/Servers/TryPostServer.php
+++ b/app/Mcp/Servers/TryPostServer.php
@@ -32,8 +32,8 @@
use App\Mcp\Tools\Repurpose\DisableRepurposeTool;
use App\Mcp\Tools\Repurpose\GetRepurposeTool;
use App\Mcp\Tools\Repurpose\ListRepurposeItemsTool;
+use App\Mcp\Tools\Repurpose\ListRepurposeSourceFormatsTool;
use App\Mcp\Tools\Repurpose\ListRepurposesTool;
-use App\Mcp\Tools\Repurpose\ListRepurposeTemplatesTool;
use App\Mcp\Tools\Repurpose\PauseRepurposeTool;
use App\Mcp\Tools\Repurpose\ResumeRepurposeTool;
use App\Mcp\Tools\Repurpose\UpdateRepurposeTool;
@@ -108,8 +108,6 @@ class TryPostServer extends Server
ListPinterestBoardsTool::class,
ListDiscordChannelsTool::class,
ToggleSocialAccountTool::class,
-
- ListRepurposeTemplatesTool::class,
ListRepurposesTool::class,
CreateRepurposeTool::class,
GetRepurposeTool::class,
@@ -119,6 +117,7 @@ class TryPostServer extends Server
ResumeRepurposeTool::class,
DisableRepurposeTool::class,
ListRepurposeItemsTool::class,
+ ListRepurposeSourceFormatsTool::class,
DeleteRepurposeTool::class,
// Webhooks
diff --git a/app/Mcp/Tools/Repurpose/ListRepurposeTemplatesTool.php b/app/Mcp/Tools/Repurpose/ListRepurposeSourceFormatsTool.php
similarity index 75%
rename from app/Mcp/Tools/Repurpose/ListRepurposeTemplatesTool.php
rename to app/Mcp/Tools/Repurpose/ListRepurposeSourceFormatsTool.php
index 0fc2beb8c..c41582c83 100644
--- a/app/Mcp/Tools/Repurpose/ListRepurposeTemplatesTool.php
+++ b/app/Mcp/Tools/Repurpose/ListRepurposeSourceFormatsTool.php
@@ -7,7 +7,6 @@
use App\Enums\Repurpose\SourceFormat;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Models\Workspace;
-use App\Support\Repurpose\Templates;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
@@ -15,9 +14,9 @@
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
-#[Description('Ready-made repurpose starting points and the video formats a source can be watched for. Use this before create-repurpose-tool to suggest a sensible source and destination combination.')]
+#[Description('List the video formats a repurpose can watch a source account for, such as reels, feed videos and stories. Use these values when creating or updating a repurpose.')]
#[IsReadOnly]
-class ListRepurposeTemplatesTool extends Tool
+class ListRepurposeSourceFormatsTool extends Tool
{
use AuthorizesMcpTool;
@@ -30,7 +29,6 @@ public function handle(Request $request): Response|ResponseFactory
}
return Response::structured([
- 'templates' => Templates::all(),
'source_formats' => array_map(
fn (SourceFormat $format): array => ['value' => $format->value, 'label' => $format->label()],
SourceFormat::cases(),
diff --git a/app/Support/Repurpose/Templates.php b/app/Support/Repurpose/Templates.php
deleted file mode 100644
index 5ebee87a7..000000000
--- a/app/Support/Repurpose/Templates.php
+++ /dev/null
@@ -1,37 +0,0 @@
-}>
- */
- public static function all(): array
- {
- return [
- [
- 'key' => 'instagram_everywhere',
- 'source_platform' => Platform::Instagram->value,
- 'destination_platforms' => [
- Platform::TikTok->value,
- Platform::YouTube->value,
- Platform::Facebook->value,
- ],
- ],
- [
- 'key' => 'facebook_everywhere',
- 'source_platform' => Platform::Facebook->value,
- 'destination_platforms' => [
- Platform::Instagram->value,
- Platform::TikTok->value,
- Platform::YouTube->value,
- ],
- ],
- ];
- }
-}
diff --git a/lang/ar/repurposes.php b/lang/ar/repurposes.php
index 7a8a988c1..6b6dcfb71 100644
--- a/lang/ar/repurposes.php
+++ b/lang/ar/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => 'معطّل',
],
- 'templates' => [
- 'use' => 'استخدام هذا القالب',
- 'instagram_everywhere' => [
- 'title' => 'Instagram في كل مكان',
- 'description' => 'انشر Reel على Instagram وسيعيد TryPost نشره على TikTok وYouTube Shorts وFacebook.',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook في كل مكان',
- 'description' => 'انشر فيديو على صفحتك في Facebook وسيعيد TryPost نشره على Instagram وTikTok وYouTube Shorts.',
- ],
- ],
-
'create' => [
'title' => 'repurpose جديد',
'description' => 'اختر الحساب الذي يجب أن يراقبه TryPost. تختار الوجهات في الشاشة التالية.',
diff --git a/lang/de/repurposes.php b/lang/de/repurposes.php
index 005cedf10..839f678e0 100644
--- a/lang/de/repurposes.php
+++ b/lang/de/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => 'Deaktiviert',
],
- 'templates' => [
- 'use' => 'Diese Vorlage verwenden',
- 'instagram_everywhere' => [
- 'title' => 'Instagram überall',
- 'description' => 'Poste ein Reel auf Instagram und TryPost veröffentlicht es erneut auf TikTok, YouTube Shorts und Facebook.',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook überall',
- 'description' => 'Poste ein Video auf deiner Facebook-Seite und TryPost veröffentlicht es erneut auf Instagram, TikTok und YouTube Shorts.',
- ],
- ],
-
'create' => [
'title' => 'Neues Repurpose',
'description' => 'Wähle das Konto, das TryPost beobachten soll. Die Ziele wählst du im nächsten Schritt.',
diff --git a/lang/el/repurposes.php b/lang/el/repurposes.php
index 7c3dba9a9..25d3eb8ca 100644
--- a/lang/el/repurposes.php
+++ b/lang/el/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => 'Απενεργοποιημένο',
],
- 'templates' => [
- 'use' => 'Χρήση προτύπου',
- 'instagram_everywhere' => [
- 'title' => 'Instagram παντού',
- 'description' => 'Ανέβασε ένα Reel στο Instagram και το TryPost το αναδημοσιεύει σε TikTok, YouTube Shorts και Facebook.',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook παντού',
- 'description' => 'Ανέβασε ένα βίντεο στη σελίδα σου στο Facebook και το TryPost το αναδημοσιεύει σε Instagram, TikTok και YouTube Shorts.',
- ],
- ],
-
'create' => [
'title' => 'Νέο repurpose',
'description' => 'Διάλεξε τον λογαριασμό που θα παρακολουθεί το TryPost. Τους προορισμούς τους επιλέγεις στην επόμενη οθόνη.',
diff --git a/lang/en/repurposes.php b/lang/en/repurposes.php
index 9acdf9318..edc23ee9b 100644
--- a/lang/en/repurposes.php
+++ b/lang/en/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => 'Disabled',
],
- 'templates' => [
- 'use' => 'Use this template',
- 'instagram_everywhere' => [
- 'title' => 'Instagram everywhere',
- 'description' => 'Post a Reel on Instagram and TryPost republishes it to TikTok, YouTube Shorts and Facebook.',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook everywhere',
- 'description' => 'Post a video on your Facebook Page and TryPost republishes it to Instagram, TikTok and YouTube Shorts.',
- ],
- ],
-
'create' => [
'title' => 'New repurpose',
'description' => 'Choose the account TryPost should watch. You pick the destinations on the next screen.',
diff --git a/lang/es/repurposes.php b/lang/es/repurposes.php
index 3e29a20ce..5dfc90649 100644
--- a/lang/es/repurposes.php
+++ b/lang/es/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => 'Desactivado',
],
- 'templates' => [
- 'use' => 'Usar esta plantilla',
- 'instagram_everywhere' => [
- 'title' => 'Instagram en todas partes',
- 'description' => 'Publica un Reel en Instagram y TryPost lo republica en TikTok, YouTube Shorts y Facebook.',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook en todas partes',
- 'description' => 'Publica un vídeo en tu página de Facebook y TryPost lo republica en Instagram, TikTok y YouTube Shorts.',
- ],
- ],
-
'create' => [
'title' => 'Nuevo repurpose',
'description' => 'Elige la cuenta que TryPost debe vigilar. Los destinos se eligen en la siguiente pantalla.',
diff --git a/lang/fr/repurposes.php b/lang/fr/repurposes.php
index 5fd4b314e..4506ebd5f 100644
--- a/lang/fr/repurposes.php
+++ b/lang/fr/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => 'Désactivé',
],
- 'templates' => [
- 'use' => 'Utiliser ce modèle',
- 'instagram_everywhere' => [
- 'title' => 'Instagram partout',
- 'description' => 'Publiez un Reel sur Instagram et TryPost le republie sur TikTok, YouTube Shorts et Facebook.',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook partout',
- 'description' => 'Publiez une vidéo sur votre Page Facebook et TryPost la republie sur Instagram, TikTok et YouTube Shorts.',
- ],
- ],
-
'create' => [
'title' => 'Nouveau repurpose',
'description' => 'Choisissez le compte que TryPost doit surveiller. Les destinations se choisissent à l\'écran suivant.',
diff --git a/lang/it/repurposes.php b/lang/it/repurposes.php
index fdad7d4c6..f5db55df4 100644
--- a/lang/it/repurposes.php
+++ b/lang/it/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => 'Disattivato',
],
- 'templates' => [
- 'use' => 'Usa questo modello',
- 'instagram_everywhere' => [
- 'title' => 'Instagram ovunque',
- 'description' => 'Pubblica un Reel su Instagram e TryPost lo ripubblica su TikTok, YouTube Shorts e Facebook.',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook ovunque',
- 'description' => 'Pubblica un video sulla tua Pagina Facebook e TryPost lo ripubblica su Instagram, TikTok e YouTube Shorts.',
- ],
- ],
-
'create' => [
'title' => 'Nuovo repurpose',
'description' => 'Scegli l\'account che TryPost deve seguire. Le destinazioni si scelgono nella schermata successiva.',
diff --git a/lang/ja/repurposes.php b/lang/ja/repurposes.php
index ddda7ded3..6c2fea2b5 100644
--- a/lang/ja/repurposes.php
+++ b/lang/ja/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => '無効',
],
- 'templates' => [
- 'use' => 'このテンプレートを使う',
- 'instagram_everywhere' => [
- 'title' => 'Instagram をどこへでも',
- 'description' => 'Instagram にリールを投稿すると、TryPost が TikTok・YouTube ショート・Facebook へ再投稿します。',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook をどこへでも',
- 'description' => 'Facebook ページに動画を投稿すると、TryPost が Instagram・TikTok・YouTube ショートへ再投稿します。',
- ],
- ],
-
'create' => [
'title' => '新しい Repurpose',
'description' => 'TryPost が見張るアカウントを選んでください。配信先は次の画面で選びます。',
diff --git a/lang/ko/repurposes.php b/lang/ko/repurposes.php
index faf737098..26b56411f 100644
--- a/lang/ko/repurposes.php
+++ b/lang/ko/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => '비활성',
],
- 'templates' => [
- 'use' => '이 템플릿 사용',
- 'instagram_everywhere' => [
- 'title' => '어디서나 Instagram',
- 'description' => 'Instagram에 릴스를 올리면 TryPost가 TikTok, YouTube Shorts, Facebook에 다시 게시합니다.',
- ],
- 'facebook_everywhere' => [
- 'title' => '어디서나 Facebook',
- 'description' => 'Facebook 페이지에 영상을 올리면 TryPost가 Instagram, TikTok, YouTube Shorts에 다시 게시합니다.',
- ],
- ],
-
'create' => [
'title' => '새 Repurpose',
'description' => 'TryPost가 지켜볼 계정을 고르세요. 대상은 다음 화면에서 선택합니다.',
diff --git a/lang/nl/repurposes.php b/lang/nl/repurposes.php
index 261b00c52..042d49ed2 100644
--- a/lang/nl/repurposes.php
+++ b/lang/nl/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => 'Uitgeschakeld',
],
- 'templates' => [
- 'use' => 'Dit sjabloon gebruiken',
- 'instagram_everywhere' => [
- 'title' => 'Instagram overal',
- 'description' => 'Plaats een Reel op Instagram en TryPost publiceert die opnieuw op TikTok, YouTube Shorts en Facebook.',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook overal',
- 'description' => 'Plaats een video op je Facebook-pagina en TryPost publiceert die opnieuw op Instagram, TikTok en YouTube Shorts.',
- ],
- ],
-
'create' => [
'title' => 'Nieuwe repurpose',
'description' => 'Kies het account dat TryPost moet volgen. De bestemmingen kies je in het volgende scherm.',
diff --git a/lang/pl/repurposes.php b/lang/pl/repurposes.php
index 3c51bd58e..cd2ab2c2d 100644
--- a/lang/pl/repurposes.php
+++ b/lang/pl/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => 'Wyłączony',
],
- 'templates' => [
- 'use' => 'Użyj tego szablonu',
- 'instagram_everywhere' => [
- 'title' => 'Instagram wszędzie',
- 'description' => 'Opublikuj Reel na Instagramie, a TryPost powtórzy go na TikToku, YouTube Shorts i Facebooku.',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook wszędzie',
- 'description' => 'Opublikuj film na swojej stronie na Facebooku, a TryPost powtórzy go na Instagramie, TikToku i YouTube Shorts.',
- ],
- ],
-
'create' => [
'title' => 'Nowy repurpose',
'description' => 'Wybierz konto, które TryPost ma obserwować. Cele wybierzesz na następnym ekranie.',
diff --git a/lang/pt-BR/repurposes.php b/lang/pt-BR/repurposes.php
index 98dfafea8..bb3ed2f42 100644
--- a/lang/pt-BR/repurposes.php
+++ b/lang/pt-BR/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => 'Desativado',
],
- 'templates' => [
- 'use' => 'Usar este template',
- 'instagram_everywhere' => [
- 'title' => 'Instagram em todo lugar',
- 'description' => 'Poste um Reel no Instagram e o TryPost republica no TikTok, no YouTube Shorts e no Facebook.',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook em todo lugar',
- 'description' => 'Poste um vídeo na sua Página do Facebook e o TryPost republica no Instagram, no TikTok e no YouTube Shorts.',
- ],
- ],
-
'create' => [
'title' => 'Novo repurpose',
'description' => 'Escolha a conta que o TryPost deve acompanhar. Os destinos você escolhe na próxima tela.',
diff --git a/lang/ru/repurposes.php b/lang/ru/repurposes.php
index d9cead14c..36e996b69 100644
--- a/lang/ru/repurposes.php
+++ b/lang/ru/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => 'Отключён',
],
- 'templates' => [
- 'use' => 'Использовать шаблон',
- 'instagram_everywhere' => [
- 'title' => 'Instagram везде',
- 'description' => 'Опубликуйте Reel в Instagram, и TryPost повторит его в TikTok, YouTube Shorts и Facebook.',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook везде',
- 'description' => 'Опубликуйте видео на своей странице Facebook, и TryPost повторит его в Instagram, TikTok и YouTube Shorts.',
- ],
- ],
-
'create' => [
'title' => 'Новый repurpose',
'description' => 'Выберите аккаунт, за которым будет следить TryPost. Назначения выбираются на следующем экране.',
diff --git a/lang/tr/repurposes.php b/lang/tr/repurposes.php
index 574913881..b94697675 100644
--- a/lang/tr/repurposes.php
+++ b/lang/tr/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => 'Devre dışı',
],
- 'templates' => [
- 'use' => 'Bu şablonu kullan',
- 'instagram_everywhere' => [
- 'title' => 'Her yerde Instagram',
- 'description' => 'Instagram\'da bir Reel paylaş, TryPost onu TikTok, YouTube Shorts ve Facebook\'ta yeniden yayınlasın.',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Her yerde Facebook',
- 'description' => 'Facebook Sayfanda bir video paylaş, TryPost onu Instagram, TikTok ve YouTube Shorts\'ta yeniden yayınlasın.',
- ],
- ],
-
'create' => [
'title' => 'Yeni repurpose',
'description' => 'TryPost\'un izlemesi gereken hesabı seç. Hedefleri bir sonraki ekranda seçeceksin.',
diff --git a/lang/uk/repurposes.php b/lang/uk/repurposes.php
index 664485738..6a58b8eae 100644
--- a/lang/uk/repurposes.php
+++ b/lang/uk/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => 'Вимкнено',
],
- 'templates' => [
- 'use' => 'Використати шаблон',
- 'instagram_everywhere' => [
- 'title' => 'Instagram усюди',
- 'description' => 'Опублікуйте Reel в Instagram, і TryPost повторить його в TikTok, YouTube Shorts і Facebook.',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook усюди',
- 'description' => 'Опублікуйте відео на своїй сторінці Facebook, і TryPost повторить його в Instagram, TikTok і YouTube Shorts.',
- ],
- ],
-
'create' => [
'title' => 'Новий repurpose',
'description' => 'Оберіть акаунт, за яким стежитиме TryPost. Призначення обираються на наступному екрані.',
diff --git a/lang/zh/repurposes.php b/lang/zh/repurposes.php
index 846af9865..abd8c419e 100644
--- a/lang/zh/repurposes.php
+++ b/lang/zh/repurposes.php
@@ -72,18 +72,6 @@
'disabled' => '已停用',
],
- 'templates' => [
- 'use' => '使用此模板',
- 'instagram_everywhere' => [
- 'title' => 'Instagram 全平台',
- 'description' => '在 Instagram 发一条 Reels,TryPost 就同步到 TikTok、YouTube Shorts 和 Facebook。',
- ],
- 'facebook_everywhere' => [
- 'title' => 'Facebook 全平台',
- 'description' => '在 Facebook 主页发一条视频,TryPost 就同步到 Instagram、TikTok 和 YouTube Shorts。',
- ],
- ],
-
'create' => [
'title' => '新建 Repurpose',
'description' => '选择 TryPost 要盯着的账号。目标平台在下一屏选择。',
diff --git a/resources/js/components/repurpose/CreateRepurposeDialog.vue b/resources/js/components/repurpose/CreateRepurposeDialog.vue
index 106d1c01d..aa076fb9a 100644
--- a/resources/js/components/repurpose/CreateRepurposeDialog.vue
+++ b/resources/js/components/repurpose/CreateRepurposeDialog.vue
@@ -22,7 +22,6 @@ import type { ChannelAccount } from '@/types/channel';
const props = defineProps<{
sourceAccounts: ChannelAccount[];
- lockedPlatform?: string | null;
}>();
const open = defineModel('open', { default: false });
@@ -31,14 +30,9 @@ const form = useForm({
source_social_account_id: '',
});
-const selectableAccounts = computed(() =>
- props.lockedPlatform
- ? props.sourceAccounts.filter((account) => account.platform === props.lockedPlatform)
- : props.sourceAccounts,
-);
const accountOptions = computed(() =>
- selectableAccounts.value.map((account) => ({
+ props.sourceAccounts.map((account) => ({
value: account.id,
label: account.display_name,
platform: account.platform,
@@ -53,7 +47,7 @@ watch(open, (isOpen) => {
return;
}
- form.source_social_account_id = selectableAccounts.value[0]?.id ?? '';
+ form.source_social_account_id = props.sourceAccounts[0]?.id ?? '';
});
const submit = () => {
@@ -73,7 +67,7 @@ const submit = () => {
{{ $t('repurposes.create.description') }}
-
-
diff --git a/resources/js/pages/repurposes/Index.vue b/resources/js/pages/repurposes/Index.vue
index 1fcfbe82d..1fd948b67 100644
--- a/resources/js/pages/repurposes/Index.vue
+++ b/resources/js/pages/repurposes/Index.vue
@@ -9,7 +9,6 @@ import EmptyState from '@/components/EmptyState.vue';
import PageHeader from '@/components/PageHeader.vue';
import CreateRepurposeDialog from '@/components/repurpose/CreateRepurposeDialog.vue';
import RepurposeFlow from '@/components/repurpose/RepurposeFlow.vue';
-import RepurposeTemplateCard from '@/components/repurpose/RepurposeTemplateCard.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -24,31 +23,24 @@ import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy, show } from '@/routes/app/repurposes';
import type { ChannelAccount } from '@/types/channel';
-import type { FlowNode, Repurpose, RepurposeTemplate } from '@/types/repurpose';
+import type { FlowNode, Repurpose } from '@/types/repurpose';
import { repurposeStatusVariant } from '@/types/repurpose-status';
const props = defineProps<{
repurposes: { data: Repurpose[] };
- templates: RepurposeTemplate[];
sourceAccounts: ChannelAccount[];
destinationAccounts: ChannelAccount[];
}>();
const createDialogOpen = ref(false);
-const activeTemplate = ref(null);
const confirmDeleteModal = ref | null>(null);
const openRepurpose = (repurpose: Repurpose) => {
router.visit(show.url(repurpose.id));
};
-const startFromTemplate = (template: RepurposeTemplate) => {
- activeTemplate.value = template;
- createDialogOpen.value = true;
-};
const startBlank = () => {
- activeTemplate.value = null;
createDialogOpen.value = true;
};
@@ -89,14 +81,10 @@ const handleDelete = (repurpose: Repurpose) => {
:description="$t('repurposes.empty.description')"
>
-
-
-
+
+
+ {{ $t('repurposes.new') }}
+
@@ -174,7 +162,6 @@ const handleDelete = (repurpose: Repurpose) => {
diff --git a/resources/js/types/repurpose.ts b/resources/js/types/repurpose.ts
index 73a98968f..addc00877 100644
--- a/resources/js/types/repurpose.ts
+++ b/resources/js/types/repurpose.ts
@@ -70,8 +70,3 @@ export interface RepurposeItem {
created_at: string;
}
-export interface RepurposeTemplate {
- key: string;
- source_platform: string;
- destination_platforms: string[];
-}
diff --git a/routes/api.php b/routes/api.php
index 0f21638e1..698dd2108 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -66,7 +66,7 @@
->name('api.social-accounts.channels');
// Repurpose
- Route::get('/repurpose-templates', [RepurposeController::class, 'templates'])->name('api.repurpose-templates.index');
+ Route::get('/repurpose-source-formats', [RepurposeController::class, 'sourceFormats'])->name('api.repurpose-source-formats.index');
Route::get('/repurposes', [RepurposeController::class, 'index'])->name('api.repurposes.index');
Route::post('/repurposes', [RepurposeController::class, 'store'])->name('api.repurposes.store');
Route::get('/repurposes/{repurpose}', [RepurposeController::class, 'show'])->name('api.repurposes.show');
diff --git a/tests/Browser/RepurposeTest.php b/tests/Browser/RepurposeTest.php
index 51d2ecaa6..d019832a6 100644
--- a/tests/Browser/RepurposeTest.php
+++ b/tests/Browser/RepurposeTest.php
@@ -44,47 +44,6 @@ function repurposeOwnerWithAccounts(): array
return [$user->fresh(), $workspace, $source, $destination];
}
-test('the empty state offers the ready-made templates', function () {
- [$user] = repurposeOwnerWithAccounts();
-
- $this->actingAs($user);
-
- $page = visit(route('app.repurposes.index'));
-
- waitForRepurposeTestId($page, 'use-template-instagram_everywhere');
-
- $page->assertRoute('app.repurposes.index')
- ->assertVisible('@use-template-instagram_everywhere')
- ->assertVisible('@use-template-facebook_everywhere')
- ->assertVisible('@create-repurpose-button')
- ->assertNoJavaScriptErrors();
-});
-
-test('using a template opens the dialog with only the matching source account', function () {
- [$user, , $source] = repurposeOwnerWithAccounts();
-
- $this->actingAs($user);
-
- $page = visit(route('app.repurposes.index'));
-
- waitForRepurposeTestId($page, 'use-template-instagram_everywhere');
-
- $page->click('@use-template-instagram_everywhere');
-
- waitForRepurposeTestId($page, 'create-repurpose-dialog');
-
- $page->assertVisible('@create-repurpose-dialog')
- ->assertVisible('@source-account-select')
- ->assertVisible('@create-repurpose-submit');
-
- $page->click('@source-account-select');
-
- waitForRepurposeTestId($page, 'source-account-option');
-
- $page->assertSee($source->display_name)
- ->assertNoJavaScriptErrors();
-});
-
test('the edit page shows the watched format, the destinations and the settings tab', function () {
[$user, $workspace, $source, $destination] = repurposeOwnerWithAccounts();
@@ -255,3 +214,16 @@ function repurposeOwnerWithAccounts(): array
->assertDontSee($withoutLink->source_media_id)
->assertNoJavaScriptErrors();
});
+
+test('the empty state offers a way to create the first repurpose', function () {
+ [$user] = repurposeOwnerWithAccounts();
+
+ $this->actingAs($user);
+
+ $page = visit(route('app.repurposes.index'));
+
+ waitForRepurposeTestId($page, 'create-repurpose-empty');
+
+ $page->assertPresent('@create-repurpose-empty')
+ ->assertNoJavaScriptErrors();
+});
diff --git a/tests/Feature/Api/RepurposeApiTest.php b/tests/Feature/Api/RepurposeApiTest.php
index 46392395b..d6c8c051c 100644
--- a/tests/Feature/Api/RepurposeApiTest.php
+++ b/tests/Feature/Api/RepurposeApiTest.php
@@ -176,14 +176,6 @@ function tiktokDestinationPayload(SocialAccount $account): array
->assertJsonPath('meta.total', 30);
});
-test('templates and source formats are listed', function () {
- $this->withHeaders(apiHeaders($this->token))
- ->getJson(route('api.repurpose-templates.index'))
- ->assertOk()
- ->assertJsonCount(2, 'data')
- ->assertJsonCount(3, 'source_formats');
-});
-
test('a repurpose from another workspace is not reachable', function () {
$stranger = Repurpose::factory()->create();
@@ -347,3 +339,11 @@ function tiktokDestinationPayload(SocialAccount $account): array
->assertOk()
->assertJsonPath('data.0.posts.0.platforms.0.status', PostPlatformStatus::Published->value);
});
+
+test('the source formats a repurpose can watch are listed', function () {
+ $this->withHeaders(apiHeaders($this->token))
+ ->getJson(route('api.repurpose-source-formats.index'))
+ ->assertOk()
+ ->assertJsonCount(count(SourceFormat::cases()), 'data')
+ ->assertJsonPath('data.0.value', SourceFormat::Reel->value);
+});
diff --git a/tests/Feature/Mcp/RepurposeToolTest.php b/tests/Feature/Mcp/RepurposeToolTest.php
index a99355ff2..bf1e2b90e 100644
--- a/tests/Feature/Mcp/RepurposeToolTest.php
+++ b/tests/Feature/Mcp/RepurposeToolTest.php
@@ -1,54 +1,3 @@
-user = User::factory()->create();
- $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
- $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
- $this->user->update(['current_workspace_id' => $this->workspace->id]);
-
- $this->source = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::Instagram]);
- $this->tiktok = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::TikTok]);
-});
-
-function tiktokDestinationForMcp(SocialAccount $account): array
-{
- return [
- 'social_account_id' => $account->id,
- 'content_type' => ContentType::TikTokVideo->value,
- 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'],
- ];
-}
test('a repurpose is created with its watched format and destination meta', function () {
$response = TryPostServer::actingAs($this->user)
@@ -187,13 +136,6 @@ function tiktokDestinationForMcp(SocialAccount $account): array
->assertSee('published_via_trypost');
});
-test('templates and source formats are listed', function () {
- TryPostServer::actingAs($this->user)
- ->tool(ListRepurposeTemplatesTool::class, [])
- ->assertOk()
- ->assertSee('instagram_everywhere');
-});
-
test('a repurpose from another workspace is not reachable', function () {
$stranger = Repurpose::factory()->create();
@@ -369,3 +311,10 @@ function tiktokDestinationForMcp(SocialAccount $account): array
expect($repurpose->fresh()->destinations)->toBe([]);
});
+
+test('the source formats tool lists what a repurpose can watch', function () {
+ TryPostServer::actingAs($this->user)
+ ->tool(ListRepurposeSourceFormatsTool::class)
+ ->assertOk()
+ ->assertSee(SourceFormat::Reel->value);
+});
diff --git a/tests/Feature/Repurpose/TranslationKeysTest.php b/tests/Feature/Repurpose/TranslationKeysTest.php
index c66844b59..e57b8ec8f 100644
--- a/tests/Feature/Repurpose/TranslationKeysTest.php
+++ b/tests/Feature/Repurpose/TranslationKeysTest.php
@@ -6,7 +6,6 @@
use App\Enums\Repurpose\ItemStatus;
use App\Enums\Repurpose\SourceFormat;
use App\Enums\Repurpose\Status;
-use App\Support\Repurpose\Templates;
function repurposeStrings(string $locale): array
{
@@ -37,9 +36,4 @@ function repurposeStrings(string $locale): array
foreach (SourceFormat::cases() as $format) {
expect(data_get($strings, "formats.{$format->value}"))->not->toBeNull();
}
-
- foreach (Templates::all() as $template) {
- expect(data_get($strings, "templates.{$template['key']}.title"))->not->toBeNull()
- ->and(data_get($strings, "templates.{$template['key']}.description"))->not->toBeNull();
- }
})->with('locales');
diff --git a/tests/Feature/Repurpose/WebTest.php b/tests/Feature/Repurpose/WebTest.php
index 220a5263a..05ca96203 100644
--- a/tests/Feature/Repurpose/WebTest.php
+++ b/tests/Feature/Repurpose/WebTest.php
@@ -42,7 +42,7 @@ function destinationPayload(SocialAccount $account): array
];
}
-test('the index lists repurposes and the ready-made templates', function () {
+test('the index lists the workspace repurposes', function () {
Repurpose::factory()->create([
'workspace_id' => $this->workspace->id,
'source_social_account_id' => $this->source->id,
@@ -55,7 +55,6 @@ function destinationPayload(SocialAccount $account): array
->component('repurposes/Index')
->has('repurposes.data', 1)
->where('repurposes.data.0.source_account.id', $this->source->id)
- ->has('templates', 2)
->has('sourceAccounts', 1));
});
From 033a92dd487f15647a69df6e3177ae307a32fc87 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 14:25:58 -0300
Subject: [PATCH 107/114] Leave the empty state with one call to action
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.
---
lang/ar/repurposes.php | 2 +-
lang/de/repurposes.php | 2 +-
lang/el/repurposes.php | 2 +-
lang/en/repurposes.php | 2 +-
lang/es/repurposes.php | 2 +-
lang/fr/repurposes.php | 2 +-
lang/it/repurposes.php | 2 +-
lang/ja/repurposes.php | 2 +-
lang/ko/repurposes.php | 2 +-
lang/nl/repurposes.php | 2 +-
lang/pl/repurposes.php | 2 +-
lang/pt-BR/repurposes.php | 2 +-
lang/ru/repurposes.php | 2 +-
lang/tr/repurposes.php | 2 +-
lang/uk/repurposes.php | 2 +-
lang/zh/repurposes.php | 2 +-
resources/js/pages/repurposes/Index.vue | 9 +--------
tests/Browser/RepurposeTest.php | 13 -------------
18 files changed, 17 insertions(+), 37 deletions(-)
diff --git a/lang/ar/repurposes.php b/lang/ar/repurposes.php
index 6b6dcfb71..882733175 100644
--- a/lang/ar/repurposes.php
+++ b/lang/ar/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'لم يتم إعداد أي repurpose بعد',
- 'description' => 'اختر نقطة بداية بالأسفل. يراقب TryPost الحساب الذي تختاره ويعيد نشر كل فيديو جديد على الشبكات التي تحددها.',
+ 'description' => 'يراقب TryPost الحساب الذي تختاره ويعيد نشر كل فيديو جديد على الشبكات التي تحددها.',
],
'table' => [
diff --git a/lang/de/repurposes.php b/lang/de/repurposes.php
index 839f678e0..fbfc8a131 100644
--- a/lang/de/repurposes.php
+++ b/lang/de/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'Noch kein Repurpose eingerichtet',
- 'description' => 'Wähle unten einen Startpunkt. TryPost beobachtet das gewählte Konto und veröffentlicht jedes neue Video erneut auf den Netzwerken deiner Wahl.',
+ 'description' => 'TryPost beobachtet das gewählte Konto und veröffentlicht jedes neue Video auf den ausgewählten Netzwerken erneut.',
],
'table' => [
diff --git a/lang/el/repurposes.php b/lang/el/repurposes.php
index 25d3eb8ca..3aed358dc 100644
--- a/lang/el/repurposes.php
+++ b/lang/el/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'Δεν έχει ρυθμιστεί repurpose ακόμη',
- 'description' => 'Διάλεξε ένα σημείο εκκίνησης παρακάτω. Το TryPost παρακολουθεί τον λογαριασμό που επιλέγεις και αναδημοσιεύει κάθε νέο βίντεο στα δίκτυα που σημειώνεις.',
+ 'description' => 'Το TryPost παρακολουθεί τον λογαριασμό που επιλέγεις και αναδημοσιεύει κάθε νέο βίντεο στα δίκτυα που σημειώνεις.',
],
'table' => [
diff --git a/lang/en/repurposes.php b/lang/en/repurposes.php
index edc23ee9b..50ba4b17e 100644
--- a/lang/en/repurposes.php
+++ b/lang/en/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'No repurpose set up yet',
- 'description' => 'Pick a starting point below. TryPost watches the account you choose and republishes every new video to the networks you pick.',
+ 'description' => 'TryPost watches the account you choose and republishes every new video to the networks you pick.',
],
'table' => [
diff --git a/lang/es/repurposes.php b/lang/es/repurposes.php
index 5dfc90649..5cb3c3c25 100644
--- a/lang/es/repurposes.php
+++ b/lang/es/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'Aún no hay ningún repurpose',
- 'description' => 'Elige un punto de partida abajo. TryPost vigila la cuenta que elijas y republica cada vídeo nuevo en las redes que marques.',
+ 'description' => 'TryPost sigue la cuenta que elijas y republica cada vídeo nuevo en las redes que marques.',
],
'table' => [
diff --git a/lang/fr/repurposes.php b/lang/fr/repurposes.php
index 4506ebd5f..e1b3d5d92 100644
--- a/lang/fr/repurposes.php
+++ b/lang/fr/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'Aucun repurpose configuré',
- 'description' => 'Choisissez un point de départ ci-dessous. TryPost surveille le compte choisi et republie chaque nouvelle vidéo sur les réseaux que vous sélectionnez.',
+ 'description' => 'TryPost surveille le compte que vous choisissez et republie chaque nouvelle vidéo sur les réseaux sélectionnés.',
],
'table' => [
diff --git a/lang/it/repurposes.php b/lang/it/repurposes.php
index f5db55df4..3b72ce977 100644
--- a/lang/it/repurposes.php
+++ b/lang/it/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'Nessun repurpose configurato',
- 'description' => 'Scegli un punto di partenza qui sotto. TryPost tiene d\'occhio l\'account scelto e ripubblica ogni nuovo video sulle reti che selezioni.',
+ 'description' => 'TryPost monitora l\'account che scegli e ripubblica ogni nuovo video sulle reti selezionate.',
],
'table' => [
diff --git a/lang/ja/repurposes.php b/lang/ja/repurposes.php
index 6c2fea2b5..4fd5cad37 100644
--- a/lang/ja/repurposes.php
+++ b/lang/ja/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'Repurpose はまだ設定されていません',
- 'description' => '下から出発点を選んでください。TryPost が選んだアカウントを見張り、新しい動画をチェックしたネットワークへ再投稿します。',
+ 'description' => 'TryPost は選んだアカウントを監視し、新しい動画を指定したネットワークに再投稿します。',
],
'table' => [
diff --git a/lang/ko/repurposes.php b/lang/ko/repurposes.php
index 26b56411f..1a5522407 100644
--- a/lang/ko/repurposes.php
+++ b/lang/ko/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => '아직 설정된 Repurpose가 없습니다',
- 'description' => '아래에서 시작점을 고르세요. TryPost가 선택한 계정을 지켜보다가 새 영상을 선택한 네트워크에 다시 게시합니다.',
+ 'description' => 'TryPost가 선택한 계정을 지켜보고 새 영상을 지정한 네트워크에 다시 게시합니다.',
],
'table' => [
diff --git a/lang/nl/repurposes.php b/lang/nl/repurposes.php
index 042d49ed2..a77ba7ce1 100644
--- a/lang/nl/repurposes.php
+++ b/lang/nl/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'Nog geen repurpose ingesteld',
- 'description' => 'Kies hieronder een startpunt. TryPost volgt het gekozen account en plaatst elke nieuwe video opnieuw op de netwerken die je aanvinkt.',
+ 'description' => 'TryPost volgt het account dat je kiest en plaatst elke nieuwe video opnieuw op de netwerken die je aanvinkt.',
],
'table' => [
diff --git a/lang/pl/repurposes.php b/lang/pl/repurposes.php
index cd2ab2c2d..d8aa41cb7 100644
--- a/lang/pl/repurposes.php
+++ b/lang/pl/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'Nie skonfigurowano jeszcze repurpose',
- 'description' => 'Wybierz punkt startowy poniżej. TryPost obserwuje wybrane konto i publikuje każdy nowy film w sieciach, które zaznaczysz.',
+ 'description' => 'TryPost obserwuje wybrane konto i publikuje każde nowe wideo w zaznaczonych sieciach.',
],
'table' => [
diff --git a/lang/pt-BR/repurposes.php b/lang/pt-BR/repurposes.php
index bb3ed2f42..21b26d432 100644
--- a/lang/pt-BR/repurposes.php
+++ b/lang/pt-BR/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'Nenhum repurpose configurado',
- 'description' => 'Escolha um ponto de partida abaixo. O TryPost acompanha a conta que você escolher e republica cada novo vídeo nas redes que você marcar.',
+ 'description' => 'O TryPost acompanha a conta que você escolher e republica cada novo vídeo nas redes que você marcar.',
],
'table' => [
diff --git a/lang/ru/repurposes.php b/lang/ru/repurposes.php
index 36e996b69..1ab6c1fce 100644
--- a/lang/ru/repurposes.php
+++ b/lang/ru/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'Repurpose ещё не настроен',
- 'description' => 'Выберите отправную точку ниже. TryPost следит за выбранным аккаунтом и заново публикует каждое новое видео в отмеченных сетях.',
+ 'description' => 'TryPost следит за выбранным аккаунтом и публикует каждое новое видео в отмеченных сетях.',
],
'table' => [
diff --git a/lang/tr/repurposes.php b/lang/tr/repurposes.php
index b94697675..95bea3299 100644
--- a/lang/tr/repurposes.php
+++ b/lang/tr/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'Henüz repurpose kurulmadı',
- 'description' => 'Aşağıdan bir başlangıç noktası seç. TryPost seçtiğin hesabı izler ve her yeni videoyu işaretlediğin ağlarda yeniden yayınlar.',
+ 'description' => 'TryPost seçtiğiniz hesabı izler ve her yeni videoyu işaretlediğiniz ağlarda yeniden paylaşır.',
],
'table' => [
diff --git a/lang/uk/repurposes.php b/lang/uk/repurposes.php
index 6a58b8eae..9fe9b8c71 100644
--- a/lang/uk/repurposes.php
+++ b/lang/uk/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => 'Repurpose ще не налаштовано',
- 'description' => 'Оберіть відправну точку нижче. TryPost стежить за обраним акаунтом і повторно публікує кожне нове відео в позначених мережах.',
+ 'description' => 'TryPost стежить за вибраним обліковим записом і публікує кожне нове відео в позначених мережах.',
],
'table' => [
diff --git a/lang/zh/repurposes.php b/lang/zh/repurposes.php
index abd8c419e..6f6e43e5e 100644
--- a/lang/zh/repurposes.php
+++ b/lang/zh/repurposes.php
@@ -53,7 +53,7 @@
'empty' => [
'title' => '还没有设置 Repurpose',
- 'description' => '在下面选一个起点。TryPost 会盯着你选的账号,把每条新视频转发到你勾选的平台。',
+ 'description' => 'TryPost 会监控你选择的账号,并将每个新视频重新发布到你勾选的网络。',
],
'table' => [
diff --git a/resources/js/pages/repurposes/Index.vue b/resources/js/pages/repurposes/Index.vue
index 1fd948b67..eb02b32bd 100644
--- a/resources/js/pages/repurposes/Index.vue
+++ b/resources/js/pages/repurposes/Index.vue
@@ -79,14 +79,7 @@ const handleDelete = (repurpose: Repurpose) => {
:icon="IconRepeat"
:title="$t('repurposes.empty.title')"
:description="$t('repurposes.empty.description')"
- >
-
-
-
- {{ $t('repurposes.new') }}
-
-
-
+ />
diff --git a/tests/Browser/RepurposeTest.php b/tests/Browser/RepurposeTest.php
index d019832a6..882975d89 100644
--- a/tests/Browser/RepurposeTest.php
+++ b/tests/Browser/RepurposeTest.php
@@ -214,16 +214,3 @@ function repurposeOwnerWithAccounts(): array
->assertDontSee($withoutLink->source_media_id)
->assertNoJavaScriptErrors();
});
-
-test('the empty state offers a way to create the first repurpose', function () {
- [$user] = repurposeOwnerWithAccounts();
-
- $this->actingAs($user);
-
- $page = visit(route('app.repurposes.index'));
-
- waitForRepurposeTestId($page, 'create-repurpose-empty');
-
- $page->assertPresent('@create-repurpose-empty')
- ->assertNoJavaScriptErrors();
-});
From de7ea964e217b75b612a9ac34aec469ac07ae401 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 14:47:39 -0300
Subject: [PATCH 108/114] Edit a repurpose the way the post editor edits a post
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 input('destinations', []),
$sourceAccountId,
);
-
- DestinationMetaRules::addRequiredErrors(
- $validator,
- (array) $this->input('destinations', []),
- $this->workspaceId(),
- );
});
}
}
diff --git a/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php b/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php
index 30df0858c..946275762 100644
--- a/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php
+++ b/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php
@@ -124,6 +124,10 @@ public function withValidator(Validator $validator): void
$sourceAccountId,
);
+ if (! DestinationMetaRules::enforcedFor($this->repurpose())) {
+ return;
+ }
+
DestinationMetaRules::addRequiredErrors(
$validator,
(array) $this->input('destinations', []),
diff --git a/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php b/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php
index ec323c455..928a72663 100644
--- a/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php
+++ b/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php
@@ -124,6 +124,10 @@ public function withValidator(Validator $validator): void
$sourceAccountId,
);
+ if (! DestinationMetaRules::enforcedFor($this->repurpose())) {
+ return;
+ }
+
DestinationMetaRules::addRequiredErrors(
$validator,
(array) $this->input('destinations', []),
diff --git a/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php b/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php
index 86b809c2e..0763ff1fb 100644
--- a/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php
+++ b/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php
@@ -10,7 +10,6 @@
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Mcp\Requests\Repurpose\CreateRepurposeRequest;
use App\Models\Workspace;
-use App\Support\Repurpose\DestinationMetaRules;
use App\Support\Repurpose\SourceIsFree;
use App\Support\Repurpose\SourceIsNotADestination;
use Illuminate\Contracts\JsonSchema\JsonSchema;
@@ -47,11 +46,6 @@ public function handle(Request $request): Response|ResponseFactory
data_get($validated, 'source_social_account_id'),
);
- DestinationMetaRules::assertRequired(
- (array) data_get($validated, 'destinations', []),
- $workspace->id,
- );
-
try {
$repurpose = CreateRepurpose::execute($workspace, $request->user(), $validated);
} catch (ValidationException $e) {
diff --git a/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php b/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php
index e9848cb69..08b148bd7 100644
--- a/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php
+++ b/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php
@@ -57,10 +57,12 @@ public function handle(Request $request): Response|ResponseFactory
data_get($validated, 'source_social_account_id', $repurpose->source_social_account_id),
);
- DestinationMetaRules::assertRequired(
- (array) data_get($validated, 'destinations', []),
- $workspace->id,
- );
+ if (DestinationMetaRules::enforcedFor($repurpose)) {
+ DestinationMetaRules::assertRequired(
+ (array) data_get($validated, 'destinations', []),
+ $workspace->id,
+ );
+ }
return Response::structured(
(new RepurposeResource(UpdateRepurpose::execute($repurpose, $validated)))->resolve(),
diff --git a/app/Support/Repurpose/DestinationMetaRules.php b/app/Support/Repurpose/DestinationMetaRules.php
index 4022b55e5..a7e6d9d59 100644
--- a/app/Support/Repurpose/DestinationMetaRules.php
+++ b/app/Support/Repurpose/DestinationMetaRules.php
@@ -4,6 +4,8 @@
namespace App\Support\Repurpose;
+use App\Enums\Repurpose\Status;
+use App\Models\Repurpose;
use App\Models\SocialAccount;
use App\Support\PostPlatformMetaRules;
use Illuminate\Support\Facades\Validator as ValidatorFacade;
@@ -38,11 +40,17 @@ public static function attributes(): array
}
/**
- * A repurpose publishes without anyone reviewing the post first, so a
+ * An active repurpose publishes without anyone reviewing the post first, so a
* destination missing the meta its network needs can only fail later, in a
- * queued job. Checked on save, the way the post editor checks it before
- * scheduling.
- *
+ * queued job. A draft, paused or disabled one saves incomplete the way a post
+ * draft does; activating it runs the same check.
+ */
+ public static function enforcedFor(Repurpose $repurpose): bool
+ {
+ return $repurpose->status === Status::Active;
+ }
+
+ /**
* @param array $destinations
*/
public static function addRequiredErrors(Validator $validator, array $destinations, ?string $workspaceId): void
diff --git a/lang/ar/repurposes.php b/lang/ar/repurposes.php
index 882733175..9ffa28720 100644
--- a/lang/ar/repurposes.php
+++ b/lang/ar/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'تُنسخ مقاطع الفيديو المنشورة على هذا الحساب خارج TryPost إلى الوجهات أدناه.',
+ 'saving' => 'جارٍ الحفظ...',
+ 'saved' => 'تم الحفظ',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => 'اختر الحسابات التي ستستقبله. ينشر كل حساب بالصيغة التي تحددها.',
'hint' => 'يُعدَّل النص لكل شبكة فقط عندما يتجاوز حد تلك الشبكة.',
'none_available' => 'لا يوجد حساب آخر متصل في مساحة العمل هذه بعد.',
- 'save' => 'حفظ التغييرات',
- 'saved' => 'تم حفظ الوجهات',
'publish_as' => 'النشر كـ',
],
diff --git a/lang/de/repurposes.php b/lang/de/repurposes.php
index fbfc8a131..4f1445312 100644
--- a/lang/de/repurposes.php
+++ b/lang/de/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'Videos, die außerhalb von TryPost auf diesem Konto erscheinen, werden auf die Ziele unten repliziert.',
+ 'saving' => 'Wird gespeichert...',
+ 'saved' => 'Gespeichert',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => 'Wähle die Konten, die es erhalten. Jedes veröffentlicht im Format deiner Wahl.',
'hint' => 'Der Text wird nur dann pro Netzwerk angepasst, wenn er dessen Limit überschreitet.',
'none_available' => 'In diesem Workspace ist noch kein weiteres Konto verbunden.',
- 'save' => 'Änderungen speichern',
- 'saved' => 'Ziele gespeichert',
'publish_as' => 'Veröffentlichen als',
],
diff --git a/lang/el/repurposes.php b/lang/el/repurposes.php
index 3aed358dc..d782dec38 100644
--- a/lang/el/repurposes.php
+++ b/lang/el/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'Τα βίντεο που δημοσιεύονται σε αυτόν τον λογαριασμό εκτός TryPost αναπαράγονται στους παρακάτω προορισμούς.',
+ 'saving' => 'Αποθήκευση...',
+ 'saved' => 'Αποθηκεύτηκε',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => 'Διάλεξε τους λογαριασμούς που θα το λάβουν. Καθένας δημοσιεύει στη μορφή που ορίζεις.',
'hint' => 'Η λεζάντα προσαρμόζεται ανά δίκτυο μόνο όταν ξεπερνά το όριο εκείνου του δικτύου.',
'none_available' => 'Δεν υπάρχει άλλος συνδεδεμένος λογαριασμός σε αυτόν τον χώρο εργασίας.',
- 'save' => 'Αποθήκευση αλλαγών',
- 'saved' => 'Οι προορισμοί αποθηκεύτηκαν',
'publish_as' => 'Δημοσίευση ως',
],
diff --git a/lang/en/repurposes.php b/lang/en/repurposes.php
index 50ba4b17e..61c4459fb 100644
--- a/lang/en/repurposes.php
+++ b/lang/en/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'Videos published on this account outside TryPost are replicated to the destinations below.',
+ 'saving' => 'Saving...',
+ 'saved' => 'Saved',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => 'Pick the accounts that receive it. Each one publishes in the format you choose.',
'hint' => 'Captions are adapted per network only when they exceed that network\'s limit.',
'none_available' => 'No other account is connected in this workspace yet.',
- 'save' => 'Save changes',
- 'saved' => 'Destinations saved',
'publish_as' => 'Publish as',
],
diff --git a/lang/es/repurposes.php b/lang/es/repurposes.php
index 5cb3c3c25..820807560 100644
--- a/lang/es/repurposes.php
+++ b/lang/es/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'Los vídeos publicados en esta cuenta fuera de TryPost se replican en los destinos de abajo.',
+ 'saving' => 'Guardando...',
+ 'saved' => 'Guardado',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => 'Elige las cuentas que lo recibirán. Cada una publica en el formato que elijas.',
'hint' => 'El texto solo se adapta por red cuando supera el límite de esa red.',
'none_available' => 'No hay ninguna otra cuenta conectada en este espacio de trabajo.',
- 'save' => 'Guardar cambios',
- 'saved' => 'Destinos guardados',
'publish_as' => 'Publicar como',
],
diff --git a/lang/fr/repurposes.php b/lang/fr/repurposes.php
index e1b3d5d92..e01c88f2c 100644
--- a/lang/fr/repurposes.php
+++ b/lang/fr/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'Les vidéos publiées sur ce compte en dehors de TryPost sont répliquées vers les destinations ci-dessous.',
+ 'saving' => 'Enregistrement...',
+ 'saved' => 'Enregistré',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => 'Choisissez les comptes qui le reçoivent. Chacun publie dans le format que vous choisissez.',
'hint' => 'La légende n\'est adaptée par réseau que lorsqu\'elle dépasse la limite de ce réseau.',
'none_available' => 'Aucun autre compte n\'est connecté dans cet espace de travail.',
- 'save' => 'Enregistrer',
- 'saved' => 'Destinations enregistrées',
'publish_as' => 'Publier comme',
],
diff --git a/lang/it/repurposes.php b/lang/it/repurposes.php
index 3b72ce977..6902afe33 100644
--- a/lang/it/repurposes.php
+++ b/lang/it/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'I video pubblicati su questo account fuori da TryPost vengono replicati sulle destinazioni qui sotto.',
+ 'saving' => 'Salvataggio in corso...',
+ 'saved' => 'Salvato',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => 'Scegli gli account che lo riceveranno. Ognuno pubblica nel formato che imposti.',
'hint' => 'La didascalia viene adattata per rete solo quando supera il limite di quella rete.',
'none_available' => 'Nessun altro account è collegato in questo workspace.',
- 'save' => 'Salva modifiche',
- 'saved' => 'Destinazioni salvate',
'publish_as' => 'Pubblica come',
],
diff --git a/lang/ja/repurposes.php b/lang/ja/repurposes.php
index 4fd5cad37..86779adbe 100644
--- a/lang/ja/repurposes.php
+++ b/lang/ja/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'このアカウントで TryPost 以外から投稿された動画が、下の配信先へ再投稿されます。',
+ 'saving' => '保存中...',
+ 'saved' => '保存しました',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => '受け取るアカウントを選びます。それぞれ、指定した形式で投稿します。',
'hint' => 'キャプションは、そのネットワークの上限を超えたときだけ調整されます。',
'none_available' => 'このワークスペースにはまだ他のアカウントが接続されていません。',
- 'save' => '変更を保存',
- 'saved' => '配信先を保存しました',
'publish_as' => '投稿形式',
],
diff --git a/lang/ko/repurposes.php b/lang/ko/repurposes.php
index 1a5522407..e575ab312 100644
--- a/lang/ko/repurposes.php
+++ b/lang/ko/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => '이 계정에서 TryPost 외부로 게시된 영상이 아래 대상으로 복제됩니다.',
+ 'saving' => '저장 중...',
+ 'saved' => '저장됨',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => '받을 계정을 고르세요. 각 계정은 지정한 형식으로 게시합니다.',
'hint' => '캡션은 해당 네트워크의 한도를 넘을 때만 조정됩니다.',
'none_available' => '이 워크스페이스에 연결된 다른 계정이 아직 없습니다.',
- 'save' => '변경사항 저장',
- 'saved' => '대상을 저장했습니다',
'publish_as' => '게시 형식',
],
diff --git a/lang/nl/repurposes.php b/lang/nl/repurposes.php
index a77ba7ce1..59ef50f2c 100644
--- a/lang/nl/repurposes.php
+++ b/lang/nl/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'Video\'s die buiten TryPost op dit account verschijnen, worden gerepliceerd naar de bestemmingen hieronder.',
+ 'saving' => 'Opslaan...',
+ 'saved' => 'Opgeslagen',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => 'Kies de accounts die het ontvangen. Elk plaatst in het formaat dat jij kiest.',
'hint' => 'Het bijschrift wordt alleen per netwerk aangepast als het de limiet van dat netwerk overschrijdt.',
'none_available' => 'Er is nog geen ander account gekoppeld in deze workspace.',
- 'save' => 'Wijzigingen opslaan',
- 'saved' => 'Bestemmingen opgeslagen',
'publish_as' => 'Plaatsen als',
],
diff --git a/lang/pl/repurposes.php b/lang/pl/repurposes.php
index d8aa41cb7..20cff0846 100644
--- a/lang/pl/repurposes.php
+++ b/lang/pl/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'Filmy opublikowane na tym koncie poza TryPost są replikowane do celów poniżej.',
+ 'saving' => 'Zapisywanie...',
+ 'saved' => 'Zapisano',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => 'Wybierz konta, które go otrzymają. Każde publikuje w wybranym przez ciebie formacie.',
'hint' => 'Opis jest dostosowywany do sieci tylko wtedy, gdy przekracza jej limit.',
'none_available' => 'W tym obszarze roboczym nie ma jeszcze innego połączonego konta.',
- 'save' => 'Zapisz zmiany',
- 'saved' => 'Cele zapisane',
'publish_as' => 'Publikuj jako',
],
diff --git a/lang/pt-BR/repurposes.php b/lang/pt-BR/repurposes.php
index 21b26d432..f1b649a3c 100644
--- a/lang/pt-BR/repurposes.php
+++ b/lang/pt-BR/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'Os vídeos publicados nesta conta fora do TryPost são replicados nos destinos abaixo.',
+ 'saving' => 'Salvando...',
+ 'saved' => 'Salvo',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => 'Escolha as contas que vão receber. Cada uma publica no formato que você definir.',
'hint' => 'A legenda só é adaptada por rede quando ultrapassa o limite daquela rede.',
'none_available' => 'Nenhuma outra conta está conectada neste workspace.',
- 'save' => 'Salvar alterações',
- 'saved' => 'Destinos salvos',
'publish_as' => 'Publicar como',
],
diff --git a/lang/ru/repurposes.php b/lang/ru/repurposes.php
index 1ab6c1fce..b3e3cf7fd 100644
--- a/lang/ru/repurposes.php
+++ b/lang/ru/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'Видео, опубликованные на этом аккаунте вне TryPost, копируются в назначения ниже.',
+ 'saving' => 'Сохранение...',
+ 'saved' => 'Сохранено',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => 'Выберите аккаунты-получатели. Каждый публикует в выбранном вами формате.',
'hint' => 'Подпись адаптируется под сеть только тогда, когда превышает её лимит.',
'none_available' => 'В этом рабочем пространстве пока нет других подключённых аккаунтов.',
- 'save' => 'Сохранить изменения',
- 'saved' => 'Назначения сохранены',
'publish_as' => 'Публиковать как',
],
diff --git a/lang/tr/repurposes.php b/lang/tr/repurposes.php
index 95bea3299..2fc832b28 100644
--- a/lang/tr/repurposes.php
+++ b/lang/tr/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'Bu hesapta TryPost dışında yayınlanan videolar aşağıdaki hedeflere kopyalanır.',
+ 'saving' => 'Kaydediliyor...',
+ 'saved' => 'Kaydedildi',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => 'Alacak hesapları seç. Her biri senin belirlediğin formatta paylaşır.',
'hint' => 'Açıklama yalnızca o ağın sınırını aştığında ağa göre uyarlanır.',
'none_available' => 'Bu çalışma alanında bağlı başka hesap yok.',
- 'save' => 'Değişiklikleri kaydet',
- 'saved' => 'Hedefler kaydedildi',
'publish_as' => 'Şu olarak paylaş',
],
diff --git a/lang/uk/repurposes.php b/lang/uk/repurposes.php
index 9fe9b8c71..f27436fa1 100644
--- a/lang/uk/repurposes.php
+++ b/lang/uk/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => 'Відео, опубліковані на цьому акаунті поза TryPost, копіюються в призначення нижче.',
+ 'saving' => 'Збереження...',
+ 'saved' => 'Збережено',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => 'Оберіть акаунти-отримувачі. Кожен публікує в обраному вами форматі.',
'hint' => 'Підпис адаптується під мережу лише тоді, коли перевищує її ліміт.',
'none_available' => 'У цьому робочому просторі поки немає інших підключених акаунтів.',
- 'save' => 'Зберегти зміни',
- 'saved' => 'Призначення збережено',
'publish_as' => 'Публікувати як',
],
diff --git a/lang/zh/repurposes.php b/lang/zh/repurposes.php
index 6f6e43e5e..589e53be5 100644
--- a/lang/zh/repurposes.php
+++ b/lang/zh/repurposes.php
@@ -88,6 +88,8 @@
'show' => [
'title' => 'Repurpose',
'description' => '这个账号在 TryPost 之外发布的视频,会同步到下面的目标。',
+ 'saving' => '保存中…',
+ 'saved' => '已保存',
],
'tabs' => [
@@ -102,8 +104,6 @@
'description' => '选择接收的账号。每个账号按你指定的格式发布。',
'hint' => '只有当文案超出该平台上限时,才会按平台调整。',
'none_available' => '这个工作区还没有连接其他账号。',
- 'save' => '保存更改',
- 'saved' => '目标已保存',
'publish_as' => '发布为',
],
diff --git a/resources/js/components/posts/editor/TikTokSettings.vue b/resources/js/components/posts/editor/TikTokSettings.vue
index a76b1c683..8bd06e751 100644
--- a/resources/js/components/posts/editor/TikTokSettings.vue
+++ b/resources/js/components/posts/editor/TikTokSettings.vue
@@ -15,6 +15,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
+import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/types/content-type';
@@ -74,6 +75,15 @@ const pickVariant = (value: string) => {
const open = ref(false);
+const errors = usePageErrors();
+const privacyError = computed(() => {
+ if (props.meta?.privacy_level) {
+ return undefined;
+ }
+
+ return Object.entries(errors.value).find(([key]) => key.endsWith('.meta.privacy_level'))?.[1];
+});
+
const updateMeta = (patch: Record) => {
emit('update:meta', { ...props.meta, ...patch });
};
@@ -260,7 +270,7 @@ watch(
- {{ repurpose.published_items_count ?? 0 }}
-
- {{ repurpose.last_polled_at ? date.diffForHumans(repurpose.last_polled_at) : '—' }}
+
+ {{ repurpose.published_items_count ?? 0 }}
-
-
-
-
+
+ {{ repurpose.last_polled_at ? date.diffForHumans(repurpose.last_polled_at) : '—' }}
@@ -157,6 +134,5 @@ const handleDelete = (repurpose: Repurpose) => {
:source-accounts="sourceAccounts"
/>
-
diff --git a/resources/js/pages/repurposes/Show.vue b/resources/js/pages/repurposes/Show.vue
index ec2ff5c49..8db7de447 100644
--- a/resources/js/pages/repurposes/Show.vue
+++ b/resources/js/pages/repurposes/Show.vue
@@ -7,6 +7,7 @@ import { computed, onUnmounted, ref, watch } from 'vue';
import ChannelConfigurator from '@/components/ChannelConfigurator.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
+import InputError from '@/components/InputError.vue';
import PublishModeCard from '@/components/repurpose/PublishModeCard.vue';
import RepurposeFlow from '@/components/repurpose/RepurposeFlow.vue';
import RepurposeHealthBanner from '@/components/repurpose/RepurposeHealthBanner.vue';
@@ -351,6 +352,12 @@ const handleDelete = () => {
@update:content-type="setDestinationContentType"
@update:meta="setDestinationMeta"
/>
+
+
diff --git a/tests/Browser/RepurposeTest.php b/tests/Browser/RepurposeTest.php
index b874193ef..f453da5ea 100644
--- a/tests/Browser/RepurposeTest.php
+++ b/tests/Browser/RepurposeTest.php
@@ -7,6 +7,7 @@
use App\Enums\Repurpose\ItemStatus;
use App\Enums\Repurpose\SourceFormat;
use App\Enums\SocialAccount\Platform;
+use App\Enums\SocialAccount\Status as AccountStatus;
use App\Enums\UserWorkspace\Role;
use App\Models\Repurpose;
use App\Models\RepurposeItem;
@@ -245,3 +246,64 @@ function repurposeOwnerWithAccounts(): array
$page->assertNoJavaScriptErrors();
});
+
+test('an autosave the backend rejects says so instead of failing quietly', function () {
+ [$user, $workspace, $source, $tiktok] = repurposeOwnerWithAccounts();
+
+ $repurpose = Repurpose::factory()->active()->create([
+ 'workspace_id' => $workspace->id,
+ 'user_id' => $user->id,
+ 'source_social_account_id' => $source->id,
+ 'destinations' => [[
+ 'social_account_id' => $tiktok->id,
+ 'content_type' => ContentType::TikTokVideo->value,
+ 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'],
+ ]],
+ ]);
+
+ $this->actingAs($user);
+
+ $page = visit(route('app.repurposes.show', $repurpose));
+
+ waitForRepurposeTestId($page, "channel-{$tiktok->id}");
+
+ $page->click("@channel-{$tiktok->id}");
+
+ waitForRepurposeTestId($page, 'destinations-error');
+
+ $page->assertVisible('@destinations-error')
+ ->assertSee(trans('repurposes.errors.destinations_required'))
+ ->assertNoJavaScriptErrors();
+
+ expect($repurpose->fresh()->destinations)->toHaveCount(1);
+});
+
+test('a source account that needs reconnecting says so in the picker', function () {
+ [$user, $workspace, $source] = repurposeOwnerWithAccounts();
+
+ config()->set('trypost.allow_multiple_social_accounts', true);
+
+ $broken = SocialAccount::factory()->for($workspace)->create([
+ 'platform' => Platform::Instagram,
+ 'status' => AccountStatus::TokenExpired,
+ ]);
+
+ $repurpose = Repurpose::factory()->create([
+ 'workspace_id' => $workspace->id,
+ 'source_social_account_id' => $source->id,
+ ]);
+
+ $this->actingAs($user);
+
+ $page = visit(route('app.repurposes.show', $repurpose));
+
+ waitForRepurposeTestId($page, 'source-account-select');
+
+ $page->click('@source-account-select');
+
+ waitForRepurposeTestId($page, "source-option-disconnected-{$broken->id}");
+
+ $page->assertVisible("@source-option-disconnected-{$broken->id}")
+ ->assertMissing("@source-option-disconnected-{$source->id}")
+ ->assertNoJavaScriptErrors();
+});
diff --git a/tests/Feature/Repurpose/ActionsTest.php b/tests/Feature/Repurpose/ActionsTest.php
index 5f4b8648d..2928dd317 100644
--- a/tests/Feature/Repurpose/ActionsTest.php
+++ b/tests/Feature/Repurpose/ActionsTest.php
@@ -283,12 +283,15 @@ function tiktokDestination(Workspace $workspace): array
expect(fn () => UpdateRepurpose::execute($repurpose, ['destinations' => []]))
->toThrow(ValidationException::class);
- $discord = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Discord]);
+ $switchedOff = SocialAccount::factory()->for($workspace)->create([
+ 'platform' => Platform::Discord,
+ 'is_active' => false,
+ ]);
expect(fn () => UpdateRepurpose::execute($repurpose, ['destinations' => [[
- 'social_account_id' => $discord->id,
+ 'social_account_id' => $switchedOff->id,
'content_type' => ContentType::DiscordMessage->value,
- 'meta' => [],
+ 'meta' => ['channel_id' => '123'],
]]]))->toThrow(ValidationException::class);
});
@@ -357,12 +360,15 @@ function tiktokDestination(Workspace $workspace): array
'destinations' => [$destination],
]);
- $pinterest = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Pinterest]);
+ $pinterest = SocialAccount::factory()->for($workspace)->create([
+ 'platform' => Platform::Pinterest,
+ 'is_active' => false,
+ ]);
expect(fn () => UpdateRepurpose::execute($repurpose, ['destinations' => [[
'social_account_id' => $pinterest->id,
- 'content_type' => ContentType::PinterestPin->value,
- 'meta' => [],
+ 'content_type' => ContentType::PinterestVideoPin->value,
+ 'meta' => ['board_id' => 'b1'],
]]]))->toThrow(ValidationException::class);
expect($repurpose->fresh()->destinations)->toEqual([$destination]);
diff --git a/tests/Feature/Repurpose/CaptionAdapterTest.php b/tests/Feature/Repurpose/CaptionAdapterTest.php
index ca38e1467..01c360442 100644
--- a/tests/Feature/Repurpose/CaptionAdapterTest.php
+++ b/tests/Feature/Repurpose/CaptionAdapterTest.php
@@ -176,3 +176,38 @@
expect($adapted)->toStartWith("First line\n\nSecond line with gaps")
->and(Platform::X->contentOverflow($adapted))->toBe(0);
});
+
+test('two networks sharing a character limit ask the shortener once, not twice', function () {
+ config()->set('trypost.self_hosted', true);
+ PostContentShortener::fake(['A tight caption that fits.']);
+
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create(['account_id' => $user->account_id, 'user_id' => $user->id]);
+
+ $caption = str_repeat('palavra ', 2000);
+ $adapter = app(CaptionAdapter::class);
+
+ $threads = $adapter->adapt($workspace, $user, $caption, Platform::Threads);
+ $mastodon = $adapter->adapt($workspace, $user, $caption, Platform::Mastodon);
+
+ expect(Platform::Threads->maxContentLength())->toBe(Platform::Mastodon->maxContentLength())
+ ->and($threads)->toBe('A tight caption that fits.')
+ ->and($mastodon)->toBe('A tight caption that fits.')
+ ->and(AiUsageLog::where('workspace_id', $workspace->id)->count())->toBe(1);
+});
+
+test('a tighter limit still gets its own call instead of reusing a longer answer', function () {
+ config()->set('trypost.self_hosted', true);
+ PostContentShortener::fake(['A tight caption that fits.', 'Short one.']);
+
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create(['account_id' => $user->account_id, 'user_id' => $user->id]);
+
+ $caption = str_repeat('palavra ', 2000);
+ $adapter = app(CaptionAdapter::class);
+
+ $adapter->adapt($workspace, $user, $caption, Platform::Threads);
+ $adapter->adapt($workspace, $user, $caption, Platform::YouTube);
+
+ expect(AiUsageLog::where('workspace_id', $workspace->id)->count())->toBe(2);
+});
diff --git a/tests/Feature/Repurpose/ProcessItemTest.php b/tests/Feature/Repurpose/ProcessItemTest.php
index 1a22437fd..51da823d6 100644
--- a/tests/Feature/Repurpose/ProcessItemTest.php
+++ b/tests/Feature/Repurpose/ProcessItemTest.php
@@ -20,6 +20,7 @@
use App\Models\Repurpose;
use App\Models\RepurposeItem;
use App\Models\SocialAccount;
+use App\Models\User;
use App\Models\Workspace;
use App\Services\Post\MediaAttacher;
use App\Services\Repurpose\CaptionAdapter;
@@ -450,7 +451,7 @@ function processItem(RepurposeItem $item, string $caption = 'My caption'): void
->and($item->fresh()->status)->toBe(ItemStatus::Failed);
});
-test('an exhausted draft-mode item keeps its drafts and says it drafted them', function () {
+test('an exhausted draft-mode item keeps its drafts but does not call the run a success', function () {
$item = repurposeWithTwoDestinations();
$item->repurpose->update(['publish_mode' => PublishMode::Draft]);
@@ -464,6 +465,39 @@ function processItem(RepurposeItem $item, string $caption = 'My caption'): void
->failed(new RuntimeException('gave up'));
expect(Post::query()->whereKey($post->id)->exists())->toBeTrue()
+ ->and($item->fresh()->status)->toBe(ItemStatus::Failed)
+ ->and($item->fresh()->error)->toContain('gave up');
+});
+
+test('a draft run that died halfway is rebuilt by the retry instead of passing as finished', function () {
+ Bus::fake([PublishPost::class]);
+ fakeVideoDownload();
+
+ $item = repurposeWithTwoDestinations();
+ $item->repurpose->update(['publish_mode' => PublishMode::Draft]);
+
+ app()->instance(CaptionAdapter::class, new class(app(ContentSanitizer::class)) extends CaptionAdapter
+ {
+ private int $calls = 0;
+
+ public function adapt(Workspace $workspace, ?User $user, string $caption, Platform $platform): string
+ {
+ if (++$this->calls === 2) {
+ throw new RuntimeException('the worker went away');
+ }
+
+ return $caption;
+ }
+ });
+
+ expect(fn () => processItem($item->fresh()))->toThrow(RuntimeException::class);
+ expect(Post::where('repurpose_item_id', $item->id)->count())->toBe(1);
+
+ app()->instance(CaptionAdapter::class, new CaptionAdapter(app(ContentSanitizer::class)));
+
+ processItem($item->fresh());
+
+ expect(Post::where('repurpose_item_id', $item->id)->count())->toBe(2)
->and($item->fresh()->status)->toBe(ItemStatus::Drafted);
});
From 1a67cfe42926ca2934f710d0ee6beca37a2ef777 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 16:21:21 -0300
Subject: [PATCH 113/114] Translate the repurpose delete dialog and finish the
pt-BR rename
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
lang/pt-BR/repurposes.php | 22 +++++++++++-----------
resources/js/pages/repurposes/Show.vue | 8 +++++++-
2 files changed, 18 insertions(+), 12 deletions(-)
diff --git a/lang/pt-BR/repurposes.php b/lang/pt-BR/repurposes.php
index 40c404fe8..8b4c12882 100644
--- a/lang/pt-BR/repurposes.php
+++ b/lang/pt-BR/repurposes.php
@@ -5,7 +5,7 @@
return [
'title' => 'Repost',
'description' => 'Reposte automaticamente nas suas outras redes o que você publica fora do TryPost.',
- 'new' => 'Novo repurpose',
+ 'new' => 'Novo repost',
'flow' => [
'no_source' => 'Sem conta de origem',
@@ -53,7 +53,7 @@
],
'empty' => [
- 'title' => 'Nenhum repurpose configurado',
+ 'title' => 'Nenhum repost configurado',
'description' => 'O TryPost acompanha a conta que você escolher e republica cada novo vídeo nas redes que você marcar.',
],
@@ -72,7 +72,7 @@
],
'create' => [
- 'title' => 'Novo repurpose',
+ 'title' => 'Novo repost',
'description' => 'Escolha a conta que o TryPost deve acompanhar. Os destinos você escolhe na próxima tela.',
'source_label' => 'Conta de origem',
'source_placeholder' => 'Escolha uma conta',
@@ -157,9 +157,9 @@
],
'danger' => [
- 'title' => 'Excluir este repurpose',
+ 'title' => 'Excluir este repost',
'description' => 'As verificações param na hora. Os posts já criados continuam no seu calendário.',
- 'delete' => 'Excluir repurpose',
+ 'delete' => 'Excluir repost',
],
'health' => [
@@ -171,17 +171,17 @@
],
'errors' => [
- 'source_already_used' => 'Esta conta já alimenta outro repurpose. Edite aquele.',
+ 'source_already_used' => 'Esta conta já alimenta outro repost. Edite aquele.',
'source_missing' => 'Escolha uma conta para monitorar antes de iniciar esta automação.',
'source_unusable' => 'Reconecte a conta que esta automação monitora antes de iniciá-la.',
'destinations_required' => 'Escolha ao menos um destino antes de ativar.',
'destination_needs_video' => 'Esse formato não aceita vídeo.',
- 'only_paused_resumes' => 'Só um repurpose pausado pode ser retomado.',
- 'only_active_pauses' => 'Só um repurpose ativo pode ser pausado.',
- 'only_running_disables' => 'Só um repurpose em execução pode ser desativado.',
- 'only_idle_activates' => 'Só um rascunho ou repurpose desativado pode ser ativado.',
+ 'only_paused_resumes' => 'Só um repost pausado pode ser retomado.',
+ 'only_active_pauses' => 'Só um repost ativo pode ser pausado.',
+ 'only_running_disables' => 'Só um repost em execução pode ser desativado.',
+ 'only_idle_activates' => 'Só um rascunho ou repost desativado pode ser ativado.',
'destination_unavailable' => 'Essa conta de destino não está mais disponível.',
- 'destination_is_source' => 'Esse destino é a própria conta que este repurpose observa.',
+ 'destination_is_source' => 'Esse destino é a própria conta que este repost observa.',
'source_unavailable' => 'Essa conta de origem não está mais disponível.',
'action_failed' => 'Algo deu errado. Confira o formulário e tente de novo.',
],
diff --git a/resources/js/pages/repurposes/Show.vue b/resources/js/pages/repurposes/Show.vue
index 8db7de447..d708c0135 100644
--- a/resources/js/pages/repurposes/Show.vue
+++ b/resources/js/pages/repurposes/Show.vue
@@ -382,6 +382,12 @@ const handleDelete = () => {
-
+
From d3845f82e2f6f38b869686d8485a6d7be0812247 Mon Sep 17 00:00:00 2001
From: Paulo Castellano
Date: Mon, 7 Sep 2026 16:39:54 -0300
Subject: [PATCH 114/114] Stop the repurpose copy from promising only videos
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../Repurpose/DestinationMetaRules.php | 6 ------
lang/ar/repurposes.php | 19 +++++++++----------
lang/de/repurposes.php | 19 +++++++++----------
lang/el/repurposes.php | 19 +++++++++----------
lang/en/repurposes.php | 19 +++++++++----------
lang/es/repurposes.php | 19 +++++++++----------
lang/fr/repurposes.php | 19 +++++++++----------
lang/it/repurposes.php | 19 +++++++++----------
lang/ja/repurposes.php | 19 +++++++++----------
lang/ko/repurposes.php | 19 +++++++++----------
lang/nl/repurposes.php | 19 +++++++++----------
lang/pl/repurposes.php | 19 +++++++++----------
lang/pt-BR/repurposes.php | 17 ++++++++---------
lang/ru/repurposes.php | 19 +++++++++----------
lang/tr/repurposes.php | 19 +++++++++----------
lang/uk/repurposes.php | 19 +++++++++----------
lang/zh/repurposes.php | 19 +++++++++----------
17 files changed, 143 insertions(+), 165 deletions(-)
diff --git a/app/Support/Repurpose/DestinationMetaRules.php b/app/Support/Repurpose/DestinationMetaRules.php
index a7e6d9d59..8c2a41fc7 100644
--- a/app/Support/Repurpose/DestinationMetaRules.php
+++ b/app/Support/Repurpose/DestinationMetaRules.php
@@ -39,12 +39,6 @@ public static function attributes(): array
return self::reKey(PostPlatformMetaRules::attributes());
}
- /**
- * An active repurpose publishes without anyone reviewing the post first, so a
- * destination missing the meta its network needs can only fail later, in a
- * queued job. A draft, paused or disabled one saves incomplete the way a post
- * draft does; activating it runs the same check.
- */
public static function enforcedFor(Repurpose $repurpose): bool
{
return $repurpose->status === Status::Active;
diff --git a/lang/ar/repurposes.php b/lang/ar/repurposes.php
index bed315778..96c92dca0 100644
--- a/lang/ar/repurposes.php
+++ b/lang/ar/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'أعد نشر مقاطع الفيديو التي تنشرها خارج TryPost على شبكاتك الأخرى تلقائيًا.',
+ 'description' => 'أعد نشر ما تنشره خارج TryPost على شبكاتك الأخرى تلقائيًا.',
'new' => 'repurpose جديد',
'flow' => [
@@ -16,7 +16,7 @@
'title' => 'النشر',
- 'description' => 'ما الذي يحدث عند ظهور فيديو جديد.',
+ 'description' => 'ما الذي يحدث عند ظهور منشور جديد.',
],
@@ -24,11 +24,11 @@
'publish' => 'النشر تلقائيًا',
- 'publish_hint' => 'تتم جدولة كل فيديو جديد فور العثور عليه.',
+ 'publish_hint' => 'تتم جدولة كل منشور جديد فور العثور عليه.',
'draft' => 'الإنشاء كمسودة',
- 'draft_hint' => 'يصبح كل فيديو جديد مسودة هنا لمراجعتها ونشرها.',
+ 'draft_hint' => 'يصبح كل منشور جديد مسودة هنا لمراجعتها ونشرها.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'المصدر',
- 'description' => 'يراقب TryPost هذا الحساب بحثًا عن مقاطع فيديو جديدة بالصيغة أدناه.',
+ 'description' => 'يراقب TryPost هذا الحساب بحثًا عن منشورات جديدة بالصيغة أدناه.',
'account_label' => 'الحساب',
'watch_label' => 'المراقبة',
'needs_reconnect' => 'يحتاج إلى إعادة اتصال',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'لم يتم إعداد أي repurpose بعد',
- 'description' => 'يراقب TryPost الحساب الذي تختاره ويعيد نشر كل فيديو جديد على الشبكات التي تحددها.',
+ 'description' => 'يراقب TryPost الحساب الذي تختاره ويعيد نشر كل منشور جديد على الشبكات التي تحددها.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => 'تُنسخ مقاطع الفيديو المنشورة على هذا الحساب خارج TryPost إلى الوجهات أدناه.',
'saving' => 'جارٍ الحفظ...',
'saved' => 'تم الحفظ',
],
@@ -114,8 +113,8 @@
'disable' => 'تعطيل',
'watermark' => 'المراقبة منذ',
'last_polled' => 'آخر فحص',
- 'draft_hint' => 'اختر وجهة واحدة على الأقل ثم فعّل. تُنسخ فقط مقاطع الفيديو المنشورة بعد التفعيل.',
- 'active_hint' => 'يفحص TryPost هذا الحساب بانتظام وينسخ كل فيديو جديد.',
+ 'draft_hint' => 'اختر وجهة واحدة على الأقل ثم فعّل. تُنسخ فقط المنشورات المنشورة بعد التفعيل.',
+ 'active_hint' => 'يفحص TryPost هذا الحساب بانتظام وينسخ كل منشور جديد.',
'paused_hint' => 'الفحوصات متوقفة. الاستئناف يكمل من حيث توقف ولا يضيع شيء نُشر في الأثناء.',
'disabled_hint' => 'معطّل. التفعيل من جديد يبدأ من الصفر: ما نشرته أثناء التعطيل يبقى خارجًا.',
],
@@ -130,7 +129,7 @@
'original_from' => 'الأصل بتاريخ :date',
'empty' => [
'title' => 'لا شيء بعد',
- 'description' => 'ستظهر هنا مقاطع الفيديو التي ينشرها هذا الحساب خارج TryPost.',
+ 'description' => 'ستظهر هنا المنشورات التي ينشرها هذا الحساب خارج TryPost.',
],
'open_post' => 'فتح المنشور',
'statuses' => [
diff --git a/lang/de/repurposes.php b/lang/de/repurposes.php
index 89e15aaba..41cbe8ded 100644
--- a/lang/de/repurposes.php
+++ b/lang/de/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'Videos, die du außerhalb von TryPost postest, automatisch auf deinen anderen Netzwerken wiederveröffentlichen.',
+ 'description' => 'Was du außerhalb von TryPost postest, automatisch auf deinen anderen Netzwerken wiederveröffentlichen.',
'new' => 'Neues Repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => 'Veröffentlichung',
- 'description' => 'Was passiert, wenn ein neues Video auftaucht.',
+ 'description' => 'Was passiert, wenn ein neuer Beitrag auftaucht.',
],
@@ -24,11 +24,11 @@
'publish' => 'Automatisch veröffentlichen',
- 'publish_hint' => 'Jedes neue Video wird eingeplant, sobald es gefunden wird.',
+ 'publish_hint' => 'Jeder neue Beitrag wird eingeplant, sobald er gefunden wird.',
'draft' => 'Als Entwurf anlegen',
- 'draft_hint' => 'Jedes neue Video wird hier zum Entwurf, den du prüfen und veröffentlichen kannst.',
+ 'draft_hint' => 'Jeder neue Beitrag wird hier zum Entwurf, den du prüfen und veröffentlichen kannst.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'Quelle',
- 'description' => 'TryPost beobachtet dieses Konto auf neue Videos im unten gewählten Format.',
+ 'description' => 'TryPost beobachtet dieses Konto auf neue Beiträge im unten gewählten Format.',
'account_label' => 'Konto',
'watch_label' => 'Beobachten',
'needs_reconnect' => 'Neu verbinden nötig',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'Noch kein Repurpose eingerichtet',
- 'description' => 'TryPost beobachtet das gewählte Konto und veröffentlicht jedes neue Video auf den ausgewählten Netzwerken erneut.',
+ 'description' => 'TryPost beobachtet das gewählte Konto und veröffentlicht jeden neuen Beitrag auf den ausgewählten Netzwerken erneut.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => 'Videos, die außerhalb von TryPost auf diesem Konto erscheinen, werden auf die Ziele unten repliziert.',
'saving' => 'Wird gespeichert...',
'saved' => 'Gespeichert',
],
@@ -114,8 +113,8 @@
'disable' => 'Deaktivieren',
'watermark' => 'Beobachtet seit',
'last_polled' => 'Zuletzt geprüft',
- 'draft_hint' => 'Wähle mindestens ein Ziel und aktiviere dann. Nur Videos nach der Aktivierung werden repliziert.',
- 'active_hint' => 'TryPost prüft dieses Konto regelmäßig und repliziert jedes neue Video.',
+ 'draft_hint' => 'Wähle mindestens ein Ziel und aktiviere dann. Nur Beiträge nach der Aktivierung werden repliziert.',
+ 'active_hint' => 'TryPost prüft dieses Konto regelmäßig und repliziert jeden neuen Beitrag.',
'paused_hint' => 'Die Prüfungen pausieren. Beim Fortsetzen geht es dort weiter, wo es aufgehört hat, nichts geht verloren.',
'disabled_hint' => 'Ausgeschaltet. Beim erneuten Aktivieren beginnt es von vorn: Was du währenddessen gepostet hast, bleibt außen vor.',
],
@@ -130,7 +129,7 @@
'original_from' => 'Original vom :date',
'empty' => [
'title' => 'Noch nichts',
- 'description' => 'Videos, die dieses Konto außerhalb von TryPost postet, erscheinen hier.',
+ 'description' => 'Beiträge, die dieses Konto außerhalb von TryPost veröffentlicht, erscheinen hier.',
],
'open_post' => 'Beitrag öffnen',
'statuses' => [
diff --git a/lang/el/repurposes.php b/lang/el/repurposes.php
index 6d359b3e8..e245ca59e 100644
--- a/lang/el/repurposes.php
+++ b/lang/el/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'Αναδημοσίευσε αυτόματα στα άλλα σου δίκτυα τα βίντεο που ανεβάζεις εκτός TryPost.',
+ 'description' => 'Αναδημοσίευσε αυτόματα στα άλλα σου δίκτυα ό,τι ανεβάζεις εκτός TryPost.',
'new' => 'Νέο repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => 'Δημοσίευση',
- 'description' => 'Τι συμβαίνει όταν εμφανίζεται νέο βίντεο.',
+ 'description' => 'Τι συμβαίνει όταν εμφανίζεται νέα ανάρτηση.',
],
@@ -24,11 +24,11 @@
'publish' => 'Αυτόματη δημοσίευση',
- 'publish_hint' => 'Κάθε νέο βίντεο προγραμματίζεται μόλις βρεθεί.',
+ 'publish_hint' => 'Κάθε νέα ανάρτηση προγραμματίζεται μόλις βρεθεί.',
'draft' => 'Δημιουργία ως πρόχειρο',
- 'draft_hint' => 'Κάθε νέο βίντεο γίνεται πρόχειρο εδώ για έλεγχο και δημοσίευση.',
+ 'draft_hint' => 'Κάθε νέα ανάρτηση γίνεται πρόχειρο εδώ για έλεγχο και δημοσίευση.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'Πηγή',
- 'description' => 'Το TryPost παρακολουθεί αυτόν τον λογαριασμό για νέα βίντεο της παρακάτω μορφής.',
+ 'description' => 'Το TryPost παρακολουθεί αυτόν τον λογαριασμό για νέες αναρτήσεις της παρακάτω μορφής.',
'account_label' => 'Λογαριασμός',
'watch_label' => 'Παρακολούθηση',
'needs_reconnect' => 'Χρειάζεται επανασύνδεση',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'Δεν έχει ρυθμιστεί repurpose ακόμη',
- 'description' => 'Το TryPost παρακολουθεί τον λογαριασμό που επιλέγεις και αναδημοσιεύει κάθε νέο βίντεο στα δίκτυα που σημειώνεις.',
+ 'description' => 'Το TryPost παρακολουθεί τον λογαριασμό που επιλέγεις και αναδημοσιεύει κάθε νέα ανάρτηση στα δίκτυα που σημειώνεις.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => 'Τα βίντεο που δημοσιεύονται σε αυτόν τον λογαριασμό εκτός TryPost αναπαράγονται στους παρακάτω προορισμούς.',
'saving' => 'Αποθήκευση...',
'saved' => 'Αποθηκεύτηκε',
],
@@ -114,8 +113,8 @@
'disable' => 'Απενεργοποίηση',
'watermark' => 'Παρακολούθηση από',
'last_polled' => 'Τελευταίος έλεγχος',
- 'draft_hint' => 'Διάλεξε τουλάχιστον έναν προορισμό και ενεργοποίησε. Αναπαράγονται μόνο βίντεο μετά την ενεργοποίηση.',
- 'active_hint' => 'Το TryPost ελέγχει τακτικά αυτόν τον λογαριασμό και αναπαράγει κάθε νέο βίντεο.',
+ 'draft_hint' => 'Διάλεξε τουλάχιστον έναν προορισμό και ενεργοποίησε. Αναπαράγονται μόνο αναρτήσεις μετά την ενεργοποίηση.',
+ 'active_hint' => 'Το TryPost ελέγχει τακτικά αυτόν τον λογαριασμό και αναπαράγει κάθε νέα ανάρτηση.',
'paused_hint' => 'Οι έλεγχοι είναι σε αναμονή. Η συνέχιση ξεκινά από εκεί που σταμάτησε και δεν χάνεται τίποτα.',
'disabled_hint' => 'Απενεργοποιημένο. Η εκ νέου ενεργοποίηση ξεκινά από την αρχή: ό,τι ανέβασες όσο ήταν κλειστό μένει εκτός.',
],
@@ -130,7 +129,7 @@
'original_from' => 'πρωτότυπο από :date',
'empty' => [
'title' => 'Τίποτα ακόμη',
- 'description' => 'Τα βίντεο που δημοσιεύει αυτός ο λογαριασμός εκτός TryPost θα εμφανίζονται εδώ.',
+ 'description' => 'Οι αναρτήσεις που κάνει αυτός ο λογαριασμός εκτός TryPost θα εμφανίζονται εδώ.',
],
'open_post' => 'Άνοιγμα ανάρτησης',
'statuses' => [
diff --git a/lang/en/repurposes.php b/lang/en/repurposes.php
index 9938d3cae..984e0e82d 100644
--- a/lang/en/repurposes.php
+++ b/lang/en/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'Replicate videos you post outside TryPost to your other networks, automatically.',
+ 'description' => 'Replicate what you post outside TryPost to your other networks, automatically.',
'new' => 'New repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => 'Publishing',
- 'description' => 'What happens when a new video shows up.',
+ 'description' => 'What happens when a new post shows up.',
],
@@ -24,11 +24,11 @@
'publish' => 'Publish automatically',
- 'publish_hint' => 'Each new video is scheduled the moment it is found.',
+ 'publish_hint' => 'Each new post is scheduled the moment it is found.',
'draft' => 'Create as draft',
- 'draft_hint' => 'Each new video becomes a draft here for you to review and publish.',
+ 'draft_hint' => 'Each new post becomes a draft here for you to review and publish.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'Source',
- 'description' => 'TryPost watches this account for new videos of the format below.',
+ 'description' => 'TryPost watches this account for new posts of the format below.',
'account_label' => 'Account',
'watch_label' => 'Watch for',
'needs_reconnect' => 'Needs reconnecting',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'No repurpose set up yet',
- 'description' => 'TryPost watches the account you choose and republishes every new video to the networks you pick.',
+ 'description' => 'TryPost watches the account you choose and republishes every new post to the networks you pick.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => 'Videos published on this account outside TryPost are replicated to the destinations below.',
'saving' => 'Saving...',
'saved' => 'Saved',
],
@@ -114,8 +113,8 @@
'disable' => 'Disable',
'watermark' => 'Watching since',
'last_polled' => 'Last checked',
- 'draft_hint' => 'Pick at least one destination, then activate. Only videos posted after you activate are replicated.',
- 'active_hint' => 'TryPost checks this account regularly and replicates every new video.',
+ 'draft_hint' => 'Pick at least one destination, then activate. Only posts published after you activate are replicated.',
+ 'active_hint' => 'TryPost checks this account regularly and replicates every new post.',
'paused_hint' => 'Checks are on hold. Resuming picks up where it stopped, so nothing posted meanwhile is lost.',
'disabled_hint' => 'Turned off. Activating again starts fresh: whatever you posted while it was off stays off.',
],
@@ -130,7 +129,7 @@
'original_from' => 'original from :date',
'empty' => [
'title' => 'Nothing yet',
- 'description' => 'Videos this account posts outside TryPost will show up here.',
+ 'description' => 'Posts this account publishes outside TryPost will show up here.',
],
'open_post' => 'Open post',
'statuses' => [
diff --git a/lang/es/repurposes.php b/lang/es/repurposes.php
index cadbdd438..160155a0d 100644
--- a/lang/es/repurposes.php
+++ b/lang/es/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'Replica automáticamente en tus otras redes los vídeos que publicas fuera de TryPost.',
+ 'description' => 'Replica automáticamente en tus otras redes lo que publicas fuera de TryPost.',
'new' => 'Nuevo repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => 'Publicación',
- 'description' => 'Qué ocurre cuando aparece un vídeo nuevo.',
+ 'description' => 'Qué ocurre cuando aparece una publicación nueva.',
],
@@ -24,11 +24,11 @@
'publish' => 'Publicar automáticamente',
- 'publish_hint' => 'Cada vídeo nuevo se programa en cuanto se encuentra.',
+ 'publish_hint' => 'Cada publicación nueva se programa en cuanto se encuentra.',
'draft' => 'Crear como borrador',
- 'draft_hint' => 'Cada vídeo nuevo se convierte en un borrador para que lo revises y publiques.',
+ 'draft_hint' => 'Cada publicación nueva se convierte en un borrador para que lo revises y publiques.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'Origen',
- 'description' => 'TryPost vigila esta cuenta en busca de vídeos nuevos del formato de abajo.',
+ 'description' => 'TryPost vigila esta cuenta en busca de publicaciones nuevas del formato de abajo.',
'account_label' => 'Cuenta',
'watch_label' => 'Vigilar',
'needs_reconnect' => 'Necesita reconectarse',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'Aún no hay ningún repurpose',
- 'description' => 'TryPost sigue la cuenta que elijas y republica cada vídeo nuevo en las redes que marques.',
+ 'description' => 'TryPost sigue la cuenta que elijas y republica cada publicación nueva en las redes que marques.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => 'Los vídeos publicados en esta cuenta fuera de TryPost se replican en los destinos de abajo.',
'saving' => 'Guardando...',
'saved' => 'Guardado',
],
@@ -114,8 +113,8 @@
'disable' => 'Desactivar',
'watermark' => 'Vigilando desde',
'last_polled' => 'Última comprobación',
- 'draft_hint' => 'Elige al menos un destino y actívalo. Solo se replican los vídeos publicados después de activarlo.',
- 'active_hint' => 'TryPost comprueba esta cuenta con regularidad y replica cada vídeo nuevo.',
+ 'draft_hint' => 'Elige al menos un destino y actívalo. Solo se replican las publicaciones hechas después de activarlo.',
+ 'active_hint' => 'TryPost comprueba esta cuenta con regularidad y replica cada publicación nueva.',
'paused_hint' => 'Las comprobaciones están detenidas. Al reanudar, continúa donde lo dejó y no se pierde nada publicado mientras tanto.',
'disabled_hint' => 'Apagado. Al activarlo de nuevo empieza desde cero: lo que publicaste mientras estaba apagado se queda fuera.',
],
@@ -130,7 +129,7 @@
'original_from' => 'original del :date',
'empty' => [
'title' => 'Nada todavía',
- 'description' => 'Los vídeos que esta cuenta publique fuera de TryPost aparecerán aquí.',
+ 'description' => 'Las publicaciones que esta cuenta haga fuera de TryPost aparecerán aquí.',
],
'open_post' => 'Abrir publicación',
'statuses' => [
diff --git a/lang/fr/repurposes.php b/lang/fr/repurposes.php
index d7caa2d45..e3176200c 100644
--- a/lang/fr/repurposes.php
+++ b/lang/fr/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'Republiez automatiquement sur vos autres réseaux les vidéos que vous postez en dehors de TryPost.',
+ 'description' => 'Republiez automatiquement sur vos autres réseaux ce que vous postez en dehors de TryPost.',
'new' => 'Nouveau repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => 'Publication',
- 'description' => 'Ce qui se passe quand une nouvelle vidéo apparaît.',
+ 'description' => 'Ce qui se passe quand une nouvelle publication apparaît.',
],
@@ -24,11 +24,11 @@
'publish' => 'Publier automatiquement',
- 'publish_hint' => 'Chaque nouvelle vidéo est programmée dès qu\'elle est trouvée.',
+ 'publish_hint' => 'Chaque nouvelle publication est programmée dès qu\'elle est trouvée.',
'draft' => 'Créer en brouillon',
- 'draft_hint' => 'Chaque nouvelle vidéo devient un brouillon à relire et publier ici.',
+ 'draft_hint' => 'Chaque nouvelle publication devient un brouillon à relire et publier ici.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'Source',
- 'description' => 'TryPost surveille ce compte pour les nouvelles vidéos du format ci-dessous.',
+ 'description' => 'TryPost surveille ce compte pour les nouvelles publications du format ci-dessous.',
'account_label' => 'Compte',
'watch_label' => 'Surveiller',
'needs_reconnect' => 'Reconnexion nécessaire',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'Aucun repurpose configuré',
- 'description' => 'TryPost surveille le compte que vous choisissez et republie chaque nouvelle vidéo sur les réseaux sélectionnés.',
+ 'description' => 'TryPost surveille le compte que vous choisissez et republie chaque nouvelle publication sur les réseaux sélectionnés.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => 'Les vidéos publiées sur ce compte en dehors de TryPost sont répliquées vers les destinations ci-dessous.',
'saving' => 'Enregistrement...',
'saved' => 'Enregistré',
],
@@ -114,8 +113,8 @@
'disable' => 'Désactiver',
'watermark' => 'Surveillé depuis',
'last_polled' => 'Dernière vérification',
- 'draft_hint' => 'Choisissez au moins une destination, puis activez. Seules les vidéos publiées après l\'activation sont répliquées.',
- 'active_hint' => 'TryPost vérifie ce compte régulièrement et réplique chaque nouvelle vidéo.',
+ 'draft_hint' => 'Choisissez au moins une destination, puis activez. Seules les publications faites après l\'activation sont répliquées.',
+ 'active_hint' => 'TryPost vérifie ce compte régulièrement et réplique chaque nouvelle publication.',
'paused_hint' => 'Les vérifications sont suspendues. La reprise repart là où elle s\'est arrêtée, rien n\'est perdu.',
'disabled_hint' => 'Désactivé. Une nouvelle activation repart de zéro : ce que vous avez publié entre-temps reste de côté.',
],
@@ -130,7 +129,7 @@
'original_from' => 'original du :date',
'empty' => [
'title' => 'Rien pour l\'instant',
- 'description' => 'Les vidéos publiées par ce compte hors de TryPost apparaîtront ici.',
+ 'description' => 'Les publications de ce compte hors de TryPost apparaîtront ici.',
],
'open_post' => 'Ouvrir la publication',
'statuses' => [
diff --git a/lang/it/repurposes.php b/lang/it/repurposes.php
index 1b81bb2ea..ac2dfcc15 100644
--- a/lang/it/repurposes.php
+++ b/lang/it/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'Ripubblica automaticamente sulle altre reti i video che pubblichi fuori da TryPost.',
+ 'description' => 'Ripubblica automaticamente sulle altre reti ciò che pubblichi fuori da TryPost.',
'new' => 'Nuovo repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => 'Pubblicazione',
- 'description' => 'Cosa succede quando compare un nuovo video.',
+ 'description' => 'Cosa succede quando compare un nuovo post.',
],
@@ -24,11 +24,11 @@
'publish' => 'Pubblica automaticamente',
- 'publish_hint' => 'Ogni nuovo video viene programmato appena viene trovato.',
+ 'publish_hint' => 'Ogni nuovo post viene programmato appena viene trovato.',
'draft' => 'Crea come bozza',
- 'draft_hint' => 'Ogni nuovo video diventa una bozza da rivedere e pubblicare qui.',
+ 'draft_hint' => 'Ogni nuovo post diventa una bozza da rivedere e pubblicare qui.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'Origine',
- 'description' => 'TryPost tiene d\'occhio questo account per i nuovi video del formato qui sotto.',
+ 'description' => 'TryPost tiene d\'occhio questo account per i nuovi post del formato qui sotto.',
'account_label' => 'Account',
'watch_label' => 'Osserva',
'needs_reconnect' => 'Da riconnettere',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'Nessun repurpose configurato',
- 'description' => 'TryPost monitora l\'account che scegli e ripubblica ogni nuovo video sulle reti selezionate.',
+ 'description' => 'TryPost monitora l\'account che scegli e ripubblica ogni nuovo post sulle reti selezionate.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => 'I video pubblicati su questo account fuori da TryPost vengono replicati sulle destinazioni qui sotto.',
'saving' => 'Salvataggio in corso...',
'saved' => 'Salvato',
],
@@ -114,8 +113,8 @@
'disable' => 'Disattiva',
'watermark' => 'In ascolto da',
'last_polled' => 'Ultimo controllo',
- 'draft_hint' => 'Scegli almeno una destinazione, poi attiva. Vengono replicati solo i video pubblicati dopo l\'attivazione.',
- 'active_hint' => 'TryPost controlla questo account con regolarità e replica ogni nuovo video.',
+ 'draft_hint' => 'Scegli almeno una destinazione, poi attiva. Vengono replicati solo i post pubblicati dopo l\'attivazione.',
+ 'active_hint' => 'TryPost controlla questo account con regolarità e replica ogni nuovo post.',
'paused_hint' => 'I controlli sono sospesi. Riprendendo si riparte da dove si era fermato e non si perde nulla.',
'disabled_hint' => 'Disattivato. Riattivandolo si riparte da zero: ciò che hai pubblicato mentre era spento resta fuori.',
],
@@ -130,7 +129,7 @@
'original_from' => 'originale del :date',
'empty' => [
'title' => 'Ancora niente',
- 'description' => 'I video che questo account pubblica fuori da TryPost appariranno qui.',
+ 'description' => 'I post che questo account pubblica fuori da TryPost appariranno qui.',
],
'open_post' => 'Apri post',
'statuses' => [
diff --git a/lang/ja/repurposes.php b/lang/ja/repurposes.php
index ff5b9b455..2987b48df 100644
--- a/lang/ja/repurposes.php
+++ b/lang/ja/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'TryPost の外で投稿した動画を、他のネットワークへ自動で再投稿します。',
+ 'description' => 'TryPost の外で投稿したものを、他のネットワークへ自動で再投稿します。',
'new' => '新しい Repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => '公開',
- 'description' => '新しい動画が見つかったときの動作。',
+ 'description' => '新しい投稿が見つかったときの動作。',
],
@@ -24,11 +24,11 @@
'publish' => '自動的に公開',
- 'publish_hint' => '新しい動画は見つかった時点で予約されます。',
+ 'publish_hint' => '新しい投稿は見つかった時点で予約されます。',
'draft' => '下書きとして作成',
- 'draft_hint' => '新しい動画はここで下書きになり、確認してから公開できます。',
+ 'draft_hint' => '新しい投稿はここで下書きになり、確認してから公開できます。',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'ソース',
- 'description' => 'TryPost がこのアカウントを見張り、下で選んだ形式の新しい動画を探します。',
+ 'description' => 'TryPost がこのアカウントを見張り、下で選んだ形式の新しい投稿を探します。',
'account_label' => 'アカウント',
'watch_label' => '監視する形式',
'needs_reconnect' => '再接続が必要',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'Repurpose はまだ設定されていません',
- 'description' => 'TryPost は選んだアカウントを監視し、新しい動画を指定したネットワークに再投稿します。',
+ 'description' => 'TryPost は選んだアカウントを監視し、新しい投稿を指定したネットワークに再投稿します。',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => 'このアカウントで TryPost 以外から投稿された動画が、下の配信先へ再投稿されます。',
'saving' => '保存中...',
'saved' => '保存しました',
],
@@ -114,8 +113,8 @@
'disable' => '無効にする',
'watermark' => '監視開始',
'last_polled' => '最終チェック',
- 'draft_hint' => '配信先を 1 つ以上選んでから有効にしてください。再投稿されるのは有効化より後の動画だけです。',
- 'active_hint' => 'TryPost はこのアカウントを定期的に確認し、新しい動画をすべて再投稿します。',
+ 'draft_hint' => '宛先を1つ以上選んでから有効にしてください。有効化した後の投稿だけが複製されます。',
+ 'active_hint' => 'TryPost はこのアカウントを定期的に確認し、新しい投稿をすべて複製します。',
'paused_hint' => 'チェックを停止中です。再開すると止まった時点から続き、その間の投稿も失われません。',
'disabled_hint' => 'オフです。もう一度有効にすると最初からになり、オフの間に投稿したものは対象外のままです。',
],
@@ -130,7 +129,7 @@
'original_from' => '元投稿 :date',
'empty' => [
'title' => 'まだ何もありません',
- 'description' => 'このアカウントが TryPost の外で投稿した動画がここに表示されます。',
+ 'description' => 'このアカウントが TryPost の外で公開した投稿がここに表示されます。',
],
'open_post' => '投稿を開く',
'statuses' => [
diff --git a/lang/ko/repurposes.php b/lang/ko/repurposes.php
index 2162242ca..15a9f5196 100644
--- a/lang/ko/repurposes.php
+++ b/lang/ko/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'TryPost 외부에서 올린 영상을 다른 네트워크에 자동으로 다시 게시합니다.',
+ 'description' => 'TryPost 외부에서 올린 것을 다른 네트워크에 자동으로 다시 게시합니다.',
'new' => '새 Repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => '게시',
- 'description' => '새 영상이 나타났을 때의 동작.',
+ 'description' => '새 게시물이 나타났을 때의 동작.',
],
@@ -24,11 +24,11 @@
'publish' => '자동으로 게시',
- 'publish_hint' => '새 영상은 발견되는 즉시 예약됩니다.',
+ 'publish_hint' => '새 게시물은 발견되는 즉시 예약됩니다.',
'draft' => '초안으로 만들기',
- 'draft_hint' => '새 영상은 여기에서 초안이 되어 검토 후 게시할 수 있습니다.',
+ 'draft_hint' => '새 게시물은 여기에서 초안이 되어 검토 후 게시할 수 있습니다.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => '소스',
- 'description' => 'TryPost가 이 계정에서 아래 형식의 새 영상을 지켜봅니다.',
+ 'description' => 'TryPost가 이 계정에서 아래 형식의 새 게시물을 지켜봅니다.',
'account_label' => '계정',
'watch_label' => '감시할 형식',
'needs_reconnect' => '다시 연결해야 함',
@@ -54,7 +54,7 @@
'empty' => [
'title' => '아직 설정된 Repurpose가 없습니다',
- 'description' => 'TryPost가 선택한 계정을 지켜보고 새 영상을 지정한 네트워크에 다시 게시합니다.',
+ 'description' => 'TryPost가 선택한 계정을 지켜보고 새 게시물을 지정한 네트워크에 다시 게시합니다.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => '이 계정에서 TryPost 외부로 게시된 영상이 아래 대상으로 복제됩니다.',
'saving' => '저장 중...',
'saved' => '저장됨',
],
@@ -114,8 +113,8 @@
'disable' => '비활성화',
'watermark' => '확인 시작',
'last_polled' => '마지막 확인',
- 'draft_hint' => '대상을 하나 이상 고른 뒤 활성화하세요. 활성화 이후에 올린 영상만 복제됩니다.',
- 'active_hint' => 'TryPost가 이 계정을 주기적으로 확인하고 새 영상을 모두 복제합니다.',
+ 'draft_hint' => '대상을 하나 이상 고른 뒤 활성화하세요. 활성화 이후에 올린 게시물만 복제됩니다.',
+ 'active_hint' => 'TryPost가 이 계정을 주기적으로 확인하고 새 게시물을 모두 복제합니다.',
'paused_hint' => '확인이 멈춰 있습니다. 재개하면 멈춘 지점부터 이어지며 그동안 올린 것도 잃지 않습니다.',
'disabled_hint' => '꺼져 있습니다. 다시 활성화하면 처음부터 시작하며, 꺼져 있는 동안 올린 것은 제외됩니다.',
],
@@ -130,7 +129,7 @@
'original_from' => '원본 :date',
'empty' => [
'title' => '아직 없음',
- 'description' => '이 계정이 TryPost 밖에서 올린 영상이 여기에 표시됩니다.',
+ 'description' => '이 계정이 TryPost 밖에서 올린 게시물이 여기에 표시됩니다.',
],
'open_post' => '게시물 열기',
'statuses' => [
diff --git a/lang/nl/repurposes.php b/lang/nl/repurposes.php
index d37701a60..83d929fb7 100644
--- a/lang/nl/repurposes.php
+++ b/lang/nl/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'Publiceer video\'s die je buiten TryPost post automatisch opnieuw op je andere netwerken.',
+ 'description' => 'Publiceer wat je buiten TryPost post automatisch opnieuw op je andere netwerken.',
'new' => 'Nieuwe repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => 'Publiceren',
- 'description' => 'Wat er gebeurt als er een nieuwe video verschijnt.',
+ 'description' => 'Wat er gebeurt als er een nieuw bericht verschijnt.',
],
@@ -24,11 +24,11 @@
'publish' => 'Automatisch publiceren',
- 'publish_hint' => 'Elke nieuwe video wordt ingepland zodra die gevonden is.',
+ 'publish_hint' => 'Elk nieuw bericht wordt ingepland zodra dat gevonden is.',
'draft' => 'Als concept aanmaken',
- 'draft_hint' => 'Elke nieuwe video wordt hier een concept om na te kijken en te publiceren.',
+ 'draft_hint' => 'Elk nieuw bericht wordt hier een concept om na te kijken en te publiceren.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'Bron',
- 'description' => 'TryPost volgt dit account op nieuwe video\'s van het formaat hieronder.',
+ 'description' => 'TryPost volgt dit account op nieuwe berichten van het formaat hieronder.',
'account_label' => 'Account',
'watch_label' => 'Volgen',
'needs_reconnect' => 'Opnieuw verbinden nodig',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'Nog geen repurpose ingesteld',
- 'description' => 'TryPost volgt het account dat je kiest en plaatst elke nieuwe video opnieuw op de netwerken die je aanvinkt.',
+ 'description' => 'TryPost volgt het account dat je kiest en plaatst elk nieuw bericht opnieuw op de netwerken die je aanvinkt.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => 'Video\'s die buiten TryPost op dit account verschijnen, worden gerepliceerd naar de bestemmingen hieronder.',
'saving' => 'Opslaan...',
'saved' => 'Opgeslagen',
],
@@ -114,8 +113,8 @@
'disable' => 'Uitschakelen',
'watermark' => 'Gevolgd sinds',
'last_polled' => 'Laatst gecontroleerd',
- 'draft_hint' => 'Kies minstens één bestemming en activeer daarna. Alleen video\'s van na de activering worden gerepliceerd.',
- 'active_hint' => 'TryPost controleert dit account regelmatig en repliceert elke nieuwe video.',
+ 'draft_hint' => 'Kies minstens één bestemming en activeer daarna. Alleen berichten van na de activering worden gerepliceerd.',
+ 'active_hint' => 'TryPost controleert dit account regelmatig en repliceert elk nieuw bericht.',
'paused_hint' => 'De controles liggen stil. Bij hervatten gaat het verder waar het stopte, er gaat niets verloren.',
'disabled_hint' => 'Uitgeschakeld. Opnieuw activeren begint schoon: wat je plaatste terwijl het uit stond, blijft buiten beschouwing.',
],
@@ -130,7 +129,7 @@
'original_from' => 'origineel van :date',
'empty' => [
'title' => 'Nog niets',
- 'description' => 'Video\'s die dit account buiten TryPost plaatst, verschijnen hier.',
+ 'description' => 'Berichten die dit account buiten TryPost plaatst, verschijnen hier.',
],
'open_post' => 'Post openen',
'statuses' => [
diff --git a/lang/pl/repurposes.php b/lang/pl/repurposes.php
index fe397fc46..306b4ed2b 100644
--- a/lang/pl/repurposes.php
+++ b/lang/pl/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'Automatycznie publikuj w pozostałych sieciach filmy, które wrzucasz poza TryPost.',
+ 'description' => 'Automatycznie publikuj w pozostałych sieciach to, co wrzucasz poza TryPost.',
'new' => 'Nowy repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => 'Publikowanie',
- 'description' => 'Co się dzieje, gdy pojawia się nowy film.',
+ 'description' => 'Co się dzieje, gdy pojawia się nowy post.',
],
@@ -24,11 +24,11 @@
'publish' => 'Publikuj automatycznie',
- 'publish_hint' => 'Każdy nowy film jest planowany zaraz po znalezieniu.',
+ 'publish_hint' => 'Każdy nowy post jest planowany zaraz po znalezieniu.',
'draft' => 'Utwórz jako wersję roboczą',
- 'draft_hint' => 'Każdy nowy film trafia tu jako wersja robocza do sprawdzenia i publikacji.',
+ 'draft_hint' => 'Każdy nowy post trafia tu jako wersja robocza do sprawdzenia i publikacji.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'Źródło',
- 'description' => 'TryPost obserwuje to konto w poszukiwaniu nowych filmów w formacie poniżej.',
+ 'description' => 'TryPost obserwuje to konto w poszukiwaniu nowych postów w formacie poniżej.',
'account_label' => 'Konto',
'watch_label' => 'Obserwuj',
'needs_reconnect' => 'Wymaga ponownego połączenia',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'Nie skonfigurowano jeszcze repurpose',
- 'description' => 'TryPost obserwuje wybrane konto i publikuje każde nowe wideo w zaznaczonych sieciach.',
+ 'description' => 'TryPost obserwuje wybrane konto i publikuje każdy nowy post w zaznaczonych sieciach.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => 'Filmy opublikowane na tym koncie poza TryPost są replikowane do celów poniżej.',
'saving' => 'Zapisywanie...',
'saved' => 'Zapisano',
],
@@ -114,8 +113,8 @@
'disable' => 'Wyłącz',
'watermark' => 'Obserwuje od',
'last_polled' => 'Ostatnie sprawdzenie',
- 'draft_hint' => 'Wybierz co najmniej jeden cel i aktywuj. Replikowane są tylko filmy opublikowane po aktywacji.',
- 'active_hint' => 'TryPost regularnie sprawdza to konto i replikuje każdy nowy film.',
+ 'draft_hint' => 'Wybierz co najmniej jeden cel i aktywuj. Replikowane są tylko posty opublikowane po aktywacji.',
+ 'active_hint' => 'TryPost regularnie sprawdza to konto i replikuje każdy nowy post.',
'paused_hint' => 'Sprawdzanie jest wstrzymane. Wznowienie kontynuuje od miejsca zatrzymania i nic nie ginie.',
'disabled_hint' => 'Wyłączone. Ponowna aktywacja zaczyna od zera: to, co opublikowałeś w międzyczasie, zostaje pominięte.',
],
@@ -130,7 +129,7 @@
'original_from' => 'oryginał z :date',
'empty' => [
'title' => 'Jeszcze nic',
- 'description' => 'Filmy publikowane przez to konto poza TryPost pojawią się tutaj.',
+ 'description' => 'Posty publikowane przez to konto poza TryPost pojawią się tutaj.',
],
'open_post' => 'Otwórz post',
'statuses' => [
diff --git a/lang/pt-BR/repurposes.php b/lang/pt-BR/repurposes.php
index 8b4c12882..ce79ef0ee 100644
--- a/lang/pt-BR/repurposes.php
+++ b/lang/pt-BR/repurposes.php
@@ -16,7 +16,7 @@
'title' => 'Publicação',
- 'description' => 'O que acontece quando um vídeo novo aparece.',
+ 'description' => 'O que acontece quando uma publicação nova aparece.',
],
@@ -24,11 +24,11 @@
'publish' => 'Publicar automaticamente',
- 'publish_hint' => 'Cada vídeo novo é agendado assim que é encontrado.',
+ 'publish_hint' => 'Cada publicação nova é agendada assim que é encontrada.',
'draft' => 'Criar como rascunho',
- 'draft_hint' => 'Cada vídeo novo vira um rascunho aqui para você revisar e publicar.',
+ 'draft_hint' => 'Cada publicação nova vira um rascunho aqui para você revisar e publicar.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'Origem',
- 'description' => 'O TryPost acompanha esta conta em busca de novos vídeos do formato abaixo.',
+ 'description' => 'O TryPost acompanha esta conta em busca de novas publicações do formato abaixo.',
'account_label' => 'Conta',
'watch_label' => 'Observar',
'needs_reconnect' => 'Precisa reconectar',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'Nenhum repost configurado',
- 'description' => 'O TryPost acompanha a conta que você escolher e republica cada novo vídeo nas redes que você marcar.',
+ 'description' => 'O TryPost acompanha a conta que você escolher e republica cada publicação nova nas redes que você marcar.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repost',
- 'description' => 'Os vídeos publicados nesta conta fora do TryPost são replicados nos destinos abaixo.',
'saving' => 'Salvando...',
'saved' => 'Salvo',
],
@@ -114,8 +113,8 @@
'disable' => 'Desativar',
'watermark' => 'Acompanhando desde',
'last_polled' => 'Última verificação',
- 'draft_hint' => 'Escolha ao menos um destino e ative. Só vídeos publicados depois da ativação são replicados.',
- 'active_hint' => 'O TryPost verifica esta conta com frequência e replica cada novo vídeo.',
+ 'draft_hint' => 'Escolha ao menos um destino e ative. Só publicações feitas depois da ativação são replicadas.',
+ 'active_hint' => 'O TryPost verifica esta conta com frequência e replica cada publicação nova.',
'paused_hint' => 'As verificações estão suspensas. Ao retomar, continua de onde parou e nada publicado nesse meio-tempo se perde.',
'disabled_hint' => 'Desligado. Ao ativar de novo, começa do zero: o que você publicou enquanto estava desligado continua de fora.',
],
@@ -130,7 +129,7 @@
'original_from' => 'original de :date',
'empty' => [
'title' => 'Nada ainda',
- 'description' => 'Os vídeos que essa conta postar fora do TryPost aparecem aqui.',
+ 'description' => 'As publicações que essa conta fizer fora do TryPost aparecem aqui.',
],
'open_post' => 'Abrir post',
'statuses' => [
diff --git a/lang/ru/repurposes.php b/lang/ru/repurposes.php
index f2d82acfa..b22e2c351 100644
--- a/lang/ru/repurposes.php
+++ b/lang/ru/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'Автоматически публикуйте в других сетях видео, которые вы выкладываете вне TryPost.',
+ 'description' => 'Автоматически публикуйте в других сетях то, что вы выкладываете вне TryPost.',
'new' => 'Новый repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => 'Публикация',
- 'description' => 'Что происходит, когда появляется новое видео.',
+ 'description' => 'Что происходит, когда появляется новая публикация.',
],
@@ -24,11 +24,11 @@
'publish' => 'Публиковать автоматически',
- 'publish_hint' => 'Каждое новое видео планируется сразу после обнаружения.',
+ 'publish_hint' => 'Каждая новая публикация планируется сразу после обнаружения.',
'draft' => 'Создавать черновик',
- 'draft_hint' => 'Каждое новое видео станет здесь черновиком для проверки и публикации.',
+ 'draft_hint' => 'Каждая новая публикация станет здесь черновиком для проверки и публикации.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'Источник',
- 'description' => 'TryPost следит за этим аккаунтом и ищет новые видео выбранного ниже формата.',
+ 'description' => 'TryPost следит за этим аккаунтом и ищет новые публикации выбранного ниже формата.',
'account_label' => 'Аккаунт',
'watch_label' => 'Отслеживать',
'needs_reconnect' => 'Требуется переподключение',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'Repurpose ещё не настроен',
- 'description' => 'TryPost следит за выбранным аккаунтом и публикует каждое новое видео в отмеченных сетях.',
+ 'description' => 'TryPost следит за выбранным аккаунтом и публикует каждую новую публикацию в отмеченных сетях.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => 'Видео, опубликованные на этом аккаунте вне TryPost, копируются в назначения ниже.',
'saving' => 'Сохранение...',
'saved' => 'Сохранено',
],
@@ -114,8 +113,8 @@
'disable' => 'Отключить',
'watermark' => 'Отслеживается с',
'last_polled' => 'Последняя проверка',
- 'draft_hint' => 'Выберите хотя бы одно назначение и активируйте. Копируются только видео, опубликованные после активации.',
- 'active_hint' => 'TryPost регулярно проверяет этот аккаунт и копирует каждое новое видео.',
+ 'draft_hint' => 'Выберите хотя бы одно назначение и активируйте. Копируются только публикации, сделанные после активации.',
+ 'active_hint' => 'TryPost регулярно проверяет этот аккаунт и копирует каждую новую публикацию.',
'paused_hint' => 'Проверки приостановлены. Возобновление продолжит с места остановки, ничего не потеряется.',
'disabled_hint' => 'Выключено. Повторная активация начнёт с нуля: опубликованное в это время останется в стороне.',
],
@@ -130,7 +129,7 @@
'original_from' => 'оригинал от :date',
'empty' => [
'title' => 'Пока ничего',
- 'description' => 'Видео, опубликованные этим аккаунтом вне TryPost, появятся здесь.',
+ 'description' => 'Публикации этого аккаунта вне TryPost появятся здесь.',
],
'open_post' => 'Открыть пост',
'statuses' => [
diff --git a/lang/tr/repurposes.php b/lang/tr/repurposes.php
index e3e0cde18..0076bc385 100644
--- a/lang/tr/repurposes.php
+++ b/lang/tr/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'TryPost dışında paylaştığın videoları diğer ağlarında otomatik olarak yeniden yayınla.',
+ 'description' => 'TryPost dışında paylaştıklarını diğer ağlarında otomatik olarak yeniden yayınla.',
'new' => 'Yeni repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => 'Yayınlama',
- 'description' => 'Yeni bir video göründüğünde ne olur.',
+ 'description' => 'Yeni bir gönderi göründüğünde ne olur.',
],
@@ -24,11 +24,11 @@
'publish' => 'Otomatik yayınla',
- 'publish_hint' => 'Her yeni video bulunduğu anda planlanır.',
+ 'publish_hint' => 'Her yeni gönderi bulunduğu anda planlanır.',
'draft' => 'Taslak olarak oluştur',
- 'draft_hint' => 'Her yeni video, gözden geçirip yayınlaman için burada taslak olur.',
+ 'draft_hint' => 'Her yeni gönderi, gözden geçirip yayınlaman için burada taslak olur.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'Kaynak',
- 'description' => 'TryPost bu hesabı aşağıdaki formattaki yeni videolar için izler.',
+ 'description' => 'TryPost bu hesabı aşağıdaki formattaki yeni gönderiler için izler.',
'account_label' => 'Hesap',
'watch_label' => 'İzle',
'needs_reconnect' => 'Yeniden bağlanmalı',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'Henüz repurpose kurulmadı',
- 'description' => 'TryPost seçtiğiniz hesabı izler ve her yeni videoyu işaretlediğiniz ağlarda yeniden paylaşır.',
+ 'description' => 'TryPost seçtiğiniz hesabı izler ve her yeni gönderiyi işaretlediğiniz ağlarda yeniden paylaşır.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => 'Bu hesapta TryPost dışında yayınlanan videolar aşağıdaki hedeflere kopyalanır.',
'saving' => 'Kaydediliyor...',
'saved' => 'Kaydedildi',
],
@@ -114,8 +113,8 @@
'disable' => 'Devre dışı bırak',
'watermark' => 'İzleme başlangıcı',
'last_polled' => 'Son kontrol',
- 'draft_hint' => 'En az bir hedef seç ve etkinleştir. Yalnızca etkinleştirmeden sonra paylaşılan videolar kopyalanır.',
- 'active_hint' => 'TryPost bu hesabı düzenli olarak kontrol eder ve her yeni videoyu kopyalar.',
+ 'draft_hint' => 'En az bir hedef seç ve etkinleştir. Yalnızca etkinleştirmeden sonra paylaşılan gönderiler kopyalanır.',
+ 'active_hint' => 'TryPost bu hesabı düzenli olarak kontrol eder ve her yeni gönderiyi kopyalar.',
'paused_hint' => 'Kontroller beklemede. Sürdürdüğünde kaldığı yerden devam eder, bu arada paylaşılan hiçbir şey kaybolmaz.',
'disabled_hint' => 'Kapalı. Yeniden etkinleştirmek sıfırdan başlar: kapalıyken paylaştıkların dışarıda kalır.',
],
@@ -130,7 +129,7 @@
'original_from' => ':date tarihli özgün gönderi',
'empty' => [
'title' => 'Henüz bir şey yok',
- 'description' => 'Bu hesabın TryPost dışında paylaştığı videolar burada görünür.',
+ 'description' => 'Bu hesabın TryPost dışında paylaştığı gönderiler burada görünür.',
],
'open_post' => 'Gönderiyi aç',
'statuses' => [
diff --git a/lang/uk/repurposes.php b/lang/uk/repurposes.php
index fe3f7ddf9..f7070702d 100644
--- a/lang/uk/repurposes.php
+++ b/lang/uk/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => 'Автоматично публікуйте в інших мережах відео, які ви викладаєте поза TryPost.',
+ 'description' => 'Автоматично публікуйте в інших мережах те, що ви викладаєте поза TryPost.',
'new' => 'Новий repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => 'Публікація',
- 'description' => 'Що відбувається, коли з\'являється нове відео.',
+ 'description' => 'Що відбувається, коли з\'являється новий допис.',
],
@@ -24,11 +24,11 @@
'publish' => 'Публікувати автоматично',
- 'publish_hint' => 'Кожне нове відео планується одразу після виявлення.',
+ 'publish_hint' => 'Кожен новий допис планується одразу після виявлення.',
'draft' => 'Створювати чернетку',
- 'draft_hint' => 'Кожне нове відео стає тут чернеткою для перевірки та публікації.',
+ 'draft_hint' => 'Кожен новий допис стає тут чернеткою для перевірки та публікації.',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => 'Джерело',
- 'description' => 'TryPost стежить за цим акаунтом і шукає нові відео обраного нижче формату.',
+ 'description' => 'TryPost стежить за цим акаунтом і шукає нові дописи обраного нижче формату.',
'account_label' => 'Обліковий запис',
'watch_label' => 'Відстежувати',
'needs_reconnect' => 'Потрібне перепідключення',
@@ -54,7 +54,7 @@
'empty' => [
'title' => 'Repurpose ще не налаштовано',
- 'description' => 'TryPost стежить за вибраним обліковим записом і публікує кожне нове відео в позначених мережах.',
+ 'description' => 'TryPost стежить за вибраним обліковим записом і публікує кожен новий допис у позначених мережах.',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => 'Відео, опубліковані на цьому акаунті поза TryPost, копіюються в призначення нижче.',
'saving' => 'Збереження...',
'saved' => 'Збережено',
],
@@ -114,8 +113,8 @@
'disable' => 'Вимкнути',
'watermark' => 'Відстежується з',
'last_polled' => 'Остання перевірка',
- 'draft_hint' => 'Оберіть щонайменше одне призначення та активуйте. Копіюються лише відео, опубліковані після активації.',
- 'active_hint' => 'TryPost регулярно перевіряє цей акаунт і копіює кожне нове відео.',
+ 'draft_hint' => 'Оберіть щонайменше одне призначення та активуйте. Копіюються лише дописи, опубліковані після активації.',
+ 'active_hint' => 'TryPost регулярно перевіряє цей акаунт і копіює кожен новий допис.',
'paused_hint' => 'Перевірки призупинено. Відновлення продовжить з місця зупинки, нічого не втратиться.',
'disabled_hint' => 'Вимкнено. Повторна активація почне з нуля: опубліковане за цей час залишиться осторонь.',
],
@@ -130,7 +129,7 @@
'original_from' => 'оригінал від :date',
'empty' => [
'title' => 'Поки нічого',
- 'description' => 'Відео, опубліковані цим обліковим записом поза TryPost, з\'являться тут.',
+ 'description' => 'Дописи цього облікового запису поза TryPost з\'являться тут.',
],
'open_post' => 'Відкрити допис',
'statuses' => [
diff --git a/lang/zh/repurposes.php b/lang/zh/repurposes.php
index 3426facd9..3d0f1f362 100644
--- a/lang/zh/repurposes.php
+++ b/lang/zh/repurposes.php
@@ -4,7 +4,7 @@
return [
'title' => 'Repurpose',
- 'description' => '把你在 TryPost 之外发布的视频,自动同步到其他平台。',
+ 'description' => '把你在 TryPost 之外发布的内容,自动同步到其他平台。',
'new' => '新建 Repurpose',
'flow' => [
@@ -16,7 +16,7 @@
'title' => '发布',
- 'description' => '发现新视频时会发生什么。',
+ 'description' => '发现新内容时会发生什么。',
],
@@ -24,11 +24,11 @@
'publish' => '自动发布',
- 'publish_hint' => '每个新视频一被发现就会排入发布计划。',
+ 'publish_hint' => '每条新内容一被发现就会排入发布计划。',
'draft' => '创建为草稿',
- 'draft_hint' => '每个新视频都会在这里生成草稿,供你检查后发布。',
+ 'draft_hint' => '每条新内容都会在这里生成草稿,供你检查后发布。',
],
@@ -40,7 +40,7 @@
'source' => [
'title' => '来源',
- 'description' => 'TryPost 会盯着这个账号,寻找下面所选格式的新视频。',
+ 'description' => 'TryPost 会盯着这个账号,寻找下面所选格式的新内容。',
'account_label' => '账号',
'watch_label' => '监控格式',
'needs_reconnect' => '需要重新连接',
@@ -54,7 +54,7 @@
'empty' => [
'title' => '还没有设置 Repurpose',
- 'description' => 'TryPost 会监控你选择的账号,并将每个新视频重新发布到你勾选的网络。',
+ 'description' => 'TryPost 会监控你选择的账号,并将每条新内容重新发布到你勾选的网络。',
],
'table' => [
@@ -86,7 +86,6 @@
'show' => [
'title' => 'Repurpose',
- 'description' => '这个账号在 TryPost 之外发布的视频,会同步到下面的目标。',
'saving' => '保存中…',
'saved' => '已保存',
],
@@ -114,8 +113,8 @@
'disable' => '停用',
'watermark' => '开始监控于',
'last_polled' => '上次检查',
- 'draft_hint' => '至少选一个目标再启用。只有启用之后发布的视频才会被同步。',
- 'active_hint' => 'TryPost 会定期检查这个账号,并同步每条新视频。',
+ 'draft_hint' => '至少选一个目标再启用。只有启用之后发布的内容才会被同步。',
+ 'active_hint' => 'TryPost 会定期检查这个账号,并同步每条新内容。',
'paused_hint' => '检查已暂停。继续后会从停下的地方接着走,期间发布的内容不会丢失。',
'disabled_hint' => '已关闭。再次启用会重新开始:关闭期间发布的内容不会被同步。',
],
@@ -130,7 +129,7 @@
'original_from' => '原帖发布于 :date',
'empty' => [
'title' => '暂无内容',
- 'description' => '该账号在 TryPost 之外发布的视频会显示在这里。',
+ 'description' => '该账号在 TryPost 之外发布的内容会显示在这里。',
],
'open_post' => '打开帖子',
'statuses' => [