diff --git a/.claude/release-assets/render-thumbnail.mjs b/.claude/release-assets/render-thumbnail.mjs index 8bab47c09..8322d4bd9 100644 --- a/.claude/release-assets/render-thumbnail.mjs +++ b/.claude/release-assets/render-thumbnail.mjs @@ -16,11 +16,11 @@ // Playwright is resolved from the repo's node_modules, so the script works // regardless of where it is invoked. -import { createRequire } from 'module'; import { readFileSync, writeFileSync, unlinkSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; +import { createRequire } from 'module'; import { tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(here, '..', '..'); diff --git a/.codex/config.toml b/.codex/config.toml deleted file mode 100644 index 217017bce..000000000 --- a/.codex/config.toml +++ /dev/null @@ -1,7 +0,0 @@ -[mcp_servers.laravel-boost] -command = "php" -args = ["artisan", "boost:mcp"] - -[mcp_servers.nightwatch] -command = "npx" -args = ["-y", "mcp-remote", "https://nightwatch.laravel.com/mcp"] diff --git a/.env.example b/.env.example index 8b76d274e..f1c92bc44 100644 --- a/.env.example +++ b/.env.example @@ -300,3 +300,6 @@ VITE_REVERB_SCHEME="${REVERB_SCHEME}" VITE_POSTHOG_ENABLED="${POSTHOG_ENABLED}" VITE_POSTHOG_API_KEY="${POSTHOG_API_KEY}" VITE_POSTHOG_HOST="${POSTHOG_HOST}" + +REPURPOSE_POLL_INTERVAL_MINUTES=15 +REPURPOSE_BACKOFF_MINUTES=60 diff --git a/.gitignore b/.gitignore index f65483e19..01ee02451 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ Homestead.yaml npm-debug.log yarn-error.log /auth.json +/.codex /.fleet /.idea /.nova diff --git a/AGENTS.md b/AGENTS.md index 9ffbdbdb5..fbbcec264 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -286,3 +286,50 @@ 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. + +`VerifyWorkspaceConnections` is the **only** thing that promotes an account back +to `Connected`, because it does so after a real `verify()` call. A successful +token refresh is not that proof — the refresh token being valid says nothing +about whether publishing still works — so `RefreshSocialToken` must not promote, +even though it would let a paused repurpose resume sooner. diff --git a/CLAUDE.md b/CLAUDE.md index 5d1d19776..0824a34c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -285,7 +285,7 @@ Self-hosted compose / `.env.example` set this `true`. When the env is unset, the - Always use normal pagination (`->paginate()`). NEVER use cursor pagination (`->cursorPaginate()`). - All paginated lists must use Inertia's scroll pagination (`Inertia::scroll()` on the backend with `` on the frontend). NEVER use traditional page-based pagination with page links/buttons. - The page size ALWAYS comes from `config('app.pagination.default')` — never a magic number, and never a `perPage`/`per_page` value supplied by the request or frontend. Action/service list methods must NOT accept a `$perPage` parameter; call `->paginate((int) config('app.pagination.default'))` directly. - - The only exception is the public REST API (`app/Http/Controllers/Api`), which uses its own fixed, documented page size (15) as a stable API contract. + - **This includes the public REST API** (`app/Http/Controllers/Api`). It used to pin its own page size of 15 as a stable contract; that exception is gone, so a list endpoint reads the same config as everything else. Changing `app.pagination.default` therefore changes the API's page size too — deliberate, and the reason a list response always carries `meta.per_page` for clients to read rather than assume. ## Form Validation @@ -434,3 +434,51 @@ 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. + +`VerifyWorkspaceConnections` is the **only** thing that promotes an account back +to `Connected`, because it does so after a real `verify()` call. A successful +token refresh is not that proof — the refresh token being valid says nothing +about whether publishing still works — so `RefreshSocialToken` must not promote, +even though it would let a paused repurpose resume sooner. 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/app/Actions/Repurpose/ActivateRepurpose.php b/app/Actions/Repurpose/ActivateRepurpose.php new file mode 100644 index 000000000..8c90dd799 --- /dev/null +++ b/app/Actions/Repurpose/ActivateRepurpose.php @@ -0,0 +1,105 @@ +update([ + 'status' => Status::Active, + 'activated_at' => now(), + 'paused_reason' => null, + 'next_poll_at' => null, + 'last_error' => null, + ]); + }, + ); + } + + public static function assertSourceUsable(Repurpose $repurpose): void + { + $account = $repurpose->loadMissing('sourceAccount')->sourceAccount; + + if ($account === null) { + throw ValidationException::withMessages([ + 'source_social_account_id' => __('repurposes.errors.source_missing'), + ]); + } + + if (! $account->is_active || $account->status !== AccountStatus::Connected) { + throw ValidationException::withMessages([ + 'source_social_account_id' => __('repurposes.errors.source_unusable'), + ]); + } + } + + public static function assertHasUsableDestination(Repurpose $repurpose): void + { + if ($repurpose->destinations === []) { + throw ValidationException::withMessages([ + 'destinations' => __('repurposes.errors.destinations_required'), + ]); + } + + if (self::usableAccounts($repurpose)->isEmpty()) { + throw ValidationException::withMessages([ + 'destinations' => __('repurposes.errors.destination_unavailable'), + ]); + } + } + + public static function assertDestinationsCarryRequiredMeta(Repurpose $repurpose): void + { + $accounts = self::usableAccounts($repurpose); + + foreach ($repurpose->destinations as $destination) { + $account = $accounts->get(data_get($destination, 'social_account_id')); + + if ($account === null) { + continue; + } + + $violation = PostPlatformMetaRules::requiredMetaViolation($account->platform, data_get($destination, 'meta')); + + if ($violation !== null) { + throw ValidationException::withMessages(['destinations' => $violation[1]]); + } + } + } + + /** + * @return Collection + */ + private static function usableAccounts(Repurpose $repurpose): Collection + { + return SocialAccount::query() + ->where('workspace_id', $repurpose->workspace_id) + ->where('is_active', true) + ->findMany(array_map( + fn (array $destination): mixed => data_get($destination, 'social_account_id'), + $repurpose->destinations, + )) + ->keyBy('id'); + } +} diff --git a/app/Actions/Repurpose/CreateRepurpose.php b/app/Actions/Repurpose/CreateRepurpose.php new file mode 100644 index 000000000..fd84ac578 --- /dev/null +++ b/app/Actions/Repurpose/CreateRepurpose.php @@ -0,0 +1,57 @@ + $data + */ + public static function execute(Workspace $workspace, User $user, array $data): Repurpose + { + $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([ + 'source_social_account_id' => __('repurposes.errors.source_already_used'), + ]); + } + + try { + return Repurpose::query()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'source_social_account_id' => $sourceAccountId, + 'source_format' => $sourceFormat, + 'publish_mode' => PublishMode::from(data_get($data, 'publish_mode', PublishMode::Publish->value)), + 'destinations' => data_get($data, 'destinations', []), + 'status' => Status::Draft, + ]); + } catch (UniqueConstraintViolationException) { + throw ValidationException::withMessages([ + 'source_social_account_id' => __('repurposes.errors.source_already_used'), + ]); + } + } + + public static function existingFor(Workspace $workspace, string $sourceAccountId, SourceFormat $format): ?Repurpose + { + return Repurpose::query() + ->where('workspace_id', $workspace->id) + ->where('source_social_account_id', $sourceAccountId) + ->where('source_format', $format) + ->first(); + } +} diff --git a/app/Actions/Repurpose/DeleteRepurpose.php b/app/Actions/Repurpose/DeleteRepurpose.php new file mode 100644 index 000000000..847458fcc --- /dev/null +++ b/app/Actions/Repurpose/DeleteRepurpose.php @@ -0,0 +1,15 @@ +delete(); + } +} diff --git a/app/Actions/Repurpose/DisableRepurpose.php b/app/Actions/Repurpose/DisableRepurpose.php new file mode 100644 index 000000000..c0b98e786 --- /dev/null +++ b/app/Actions/Repurpose/DisableRepurpose.php @@ -0,0 +1,27 @@ + $locked->update([ + 'status' => Status::Disabled, + 'activated_at' => null, + 'paused_reason' => null, + 'next_poll_at' => null, + ]), + ); + } +} diff --git a/app/Actions/Repurpose/ListRepurposeItems.php b/app/Actions/Repurpose/ListRepurposeItems.php new file mode 100644 index 000000000..30637a149 --- /dev/null +++ b/app/Actions/Repurpose/ListRepurposeItems.php @@ -0,0 +1,24 @@ + + */ + public static function execute(Repurpose $repurpose, ?int $page = 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'), page: $page); + } +} diff --git a/app/Actions/Repurpose/ListRepurposes.php b/app/Actions/Repurpose/ListRepurposes.php new file mode 100644 index 000000000..c7e9543a2 --- /dev/null +++ b/app/Actions/Repurpose/ListRepurposes.php @@ -0,0 +1,26 @@ + + */ + public static function execute(Workspace $workspace, ?int $page = 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); + } +} diff --git a/app/Actions/Repurpose/PauseRepurpose.php b/app/Actions/Repurpose/PauseRepurpose.php new file mode 100644 index 000000000..5a2a1d75b --- /dev/null +++ b/app/Actions/Repurpose/PauseRepurpose.php @@ -0,0 +1,22 @@ + $locked->update(['status' => Status::Paused]), + ); + } +} diff --git a/app/Actions/Repurpose/ResumeRepurpose.php b/app/Actions/Repurpose/ResumeRepurpose.php new file mode 100644 index 000000000..0dc6adbc7 --- /dev/null +++ b/app/Actions/Repurpose/ResumeRepurpose.php @@ -0,0 +1,33 @@ +update([ + 'status' => Status::Active, + '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 new file mode 100644 index 000000000..15a2f7296 --- /dev/null +++ b/app/Actions/Repurpose/UpdateRepurpose.php @@ -0,0 +1,53 @@ + $data + */ + public static function execute(Repurpose $repurpose, array $data): Repurpose + { + $attributes = Arr::only($data, [ + 'source_social_account_id', + 'source_format', + 'publish_mode', + 'destinations', + ]); + + try { + return DB::transaction(function () use ($repurpose, $attributes): Repurpose { + $locked = Repurpose::query()->whereKey($repurpose->id)->lockForUpdate()->firstOrFail(); + + $locked->fill($attributes); + + if ($locked->isDirty(['source_social_account_id', 'source_format']) && $locked->activated_at !== null) { + $locked->activated_at = now(); + } + + $locked->save(); + $locked = $locked->fresh(); + + if ($locked->status === Status::Active) { + ActivateRepurpose::assertHasUsableDestination($locked); + } + + return $locked; + }); + } catch (UniqueConstraintViolationException) { + throw ValidationException::withMessages([ + 'source_social_account_id' => __('repurposes.errors.source_already_used'), + ]); + } + } +} diff --git a/app/Ai/Agents/PostContentShortener.php b/app/Ai/Agents/PostContentShortener.php new file mode 100644 index 000000000..ad0b180ce --- /dev/null +++ b/app/Ai/Agents/PostContentShortener.php @@ -0,0 +1,33 @@ + $this->workspace->name ?? '', + 'brand_voice_traits' => $this->workspace->brand_voice_traits ?? [], + 'platform_label' => $this->platformLabel, + 'limit' => $this->limit, + 'target' => max(1, (int) floor($this->limit * 0.95)), + ])->render(); + } +} diff --git a/app/Console/Commands/CheckUpcomingPostConnections.php b/app/Console/Commands/CheckUpcomingPostConnections.php index 2a6bf4500..7a0da4707 100644 --- a/app/Console/Commands/CheckUpcomingPostConnections.php +++ b/app/Console/Commands/CheckUpcomingPostConnections.php @@ -40,6 +40,5 @@ public function handle(): void VerifyUpcomingPostConnections::dispatch($workspaceId); } - $this->info("Dispatched {$workspaceIds->count()} upcoming-post connection checks."); } } diff --git a/app/Console/Commands/RecoverStuckPosts.php b/app/Console/Commands/RecoverStuckPosts.php index a8f74498d..0b4dc1bab 100644 --- a/app/Console/Commands/RecoverStuckPosts.php +++ b/app/Console/Commands/RecoverStuckPosts.php @@ -81,6 +81,5 @@ public function handle(): void $count++; }); - $this->info("Recovered {$count} stuck posts."); } } diff --git a/app/Console/Commands/RefreshExpiringTokens.php b/app/Console/Commands/RefreshExpiringTokens.php index 5e4218a03..f909d527f 100644 --- a/app/Console/Commands/RefreshExpiringTokens.php +++ b/app/Console/Commands/RefreshExpiringTokens.php @@ -47,6 +47,5 @@ public function handle(): void // Accounts in the window, not jobs queued: the job is unique per // account, so a dispatch during a backlog is silently discarded. - $this->info("{$count} accounts due for a token refresh."); } } diff --git a/app/Console/Commands/Repurpose/PollRepurposes.php b/app/Console/Commands/Repurpose/PollRepurposes.php new file mode 100644 index 000000000..2671afad8 --- /dev/null +++ b/app/Console/Commands/Repurpose/PollRepurposes.php @@ -0,0 +1,39 @@ +where('status', Status::Active) + ->where(fn (Builder $query) => $query->whereNull('next_poll_at')->orWhere('next_poll_at', '<=', now())) + ->distinct() + ->pluck('source_social_account_id') + ->filter(); + + SocialAccount::query() + ->whereKey($accountIds) + ->chunkById(100, function ($accounts): void { + foreach ($accounts as $account) { + PollRepurposeSource::dispatch($account); + } + }); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RetryFailedPost.php b/app/Console/Commands/RetryFailedPost.php index 6bc7e4ae2..3e4a292ea 100644 --- a/app/Console/Commands/RetryFailedPost.php +++ b/app/Console/Commands/RetryFailedPost.php @@ -68,7 +68,6 @@ public function handle(): int ); if (! $this->confirm('Queue publish attempts for these failed platforms?')) { - $this->info('Retry cancelled.'); return self::SUCCESS; } @@ -95,8 +94,6 @@ public function handle(): int 'post_platform_ids' => array_column($retryEntries, 'id'), ]); - $this->info(count($retryEntries).' publish attempt(s) queued.'); - return self::SUCCESS; } diff --git a/app/Console/Commands/Telegram/SetWebhook.php b/app/Console/Commands/Telegram/SetWebhook.php index 28a473c9a..5fe07a562 100644 --- a/app/Console/Commands/Telegram/SetWebhook.php +++ b/app/Console/Commands/Telegram/SetWebhook.php @@ -24,8 +24,6 @@ public function handle(): int return self::FAILURE; } - $this->info("Telegram webhook registered at {$url}"); - return self::SUCCESS; } } 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/Dto/SourceMedia.php b/app/Dto/SourceMedia.php new file mode 100644 index 000000000..fc2cb1e94 --- /dev/null +++ b/app/Dto/SourceMedia.php @@ -0,0 +1,27 @@ +createdAt !== null + && $this->createdAt->lessThanOrEqualTo($watermark); + } +} diff --git a/app/Enums/Facebook/StoryMediaType.php b/app/Enums/Facebook/StoryMediaType.php new file mode 100644 index 000000000..8bf44e2ae --- /dev/null +++ b/app/Enums/Facebook/StoryMediaType.php @@ -0,0 +1,14 @@ +value}"); + } + + public function description(): string + { + return __("repurposes.publish_modes.{$this->value}_hint"); + } +} diff --git a/app/Enums/Repurpose/SourceFormat.php b/app/Enums/Repurpose/SourceFormat.php new file mode 100644 index 000000000..69b345afa --- /dev/null +++ b/app/Enums/Repurpose/SourceFormat.php @@ -0,0 +1,61 @@ +value}"); + } + + /** + * @return array + */ + public static function forPlatform(Platform $platform): array + { + return match ($platform) { + Platform::Instagram, Platform::InstagramFacebook, Platform::Facebook => [self::Reel, self::Video, self::Story], + default => [], + }; + } + + public function defaultContentTypeFor(Platform $platform): ContentType + { + $candidates = match ($this) { + self::Reel, self::Video => [ContentType::InstagramReel, ContentType::FacebookReel, ContentType::TikTokVideo, ContentType::YouTubeShort], + self::Story => [ContentType::InstagramStory, ContentType::FacebookStory, ContentType::TikTokVideo, ContentType::YouTubeShort], + }; + + $available = self::videoContentTypesFor($platform); + + foreach ($candidates as $contentType) { + if (in_array($contentType, $available, true)) { + return $contentType; + } + } + + return Arr::first($available) ?? ContentType::defaultFor($platform); + } + + /** + * @return array + */ + public static function videoContentTypesFor(Platform $platform): array + { + return array_values(array_filter( + ContentType::forPlatform($platform), + fn (ContentType $contentType): bool => $contentType->supportsVideo(), + )); + } +} diff --git a/app/Enums/Repurpose/Status.php b/app/Enums/Repurpose/Status.php new file mode 100644 index 000000000..4ec8298db --- /dev/null +++ b/app/Enums/Repurpose/Status.php @@ -0,0 +1,13 @@ +json(), 'error.message', $response->body())); + } + + public function isTransient(): bool + { + return GraphError::isTransientFailure($this->response); + } + + public function isUnknownField(): bool + { + return (int) data_get($this->response->json(), 'error.code') === 100; + } +} diff --git a/app/Http/Controllers/Api/PostController.php b/app/Http/Controllers/Api/PostController.php index d7fa0e77d..d7724ce38 100644 --- a/app/Http/Controllers/Api/PostController.php +++ b/app/Http/Controllers/Api/PostController.php @@ -37,7 +37,7 @@ public function index(Request $request): AnonymousResourceCollection $posts = $request->user()->currentWorkspace->posts() ->with(['postPlatforms.socialAccount', 'user', 'labels']) ->latest('scheduled_at') - ->paginate(15); + ->paginate((int) config('app.pagination.default')); return PostResource::collection($posts); } diff --git a/app/Http/Controllers/Api/RepurposeController.php b/app/Http/Controllers/Api/RepurposeController.php new file mode 100644 index 000000000..fb9165f9f --- /dev/null +++ b/app/Http/Controllers/Api/RepurposeController.php @@ -0,0 +1,124 @@ +authorize('viewAny', Repurpose::class); + + return RepurposeResource::collection( + ListRepurposes::execute($request->user()->currentWorkspace), + ); + } + + public function store(StoreRepurposeRequest $request): JsonResponse + { + $this->authorize('create', Repurpose::class); + + $repurpose = CreateRepurpose::execute( + $request->user()->currentWorkspace, + $request->user(), + $request->validated(), + ); + + return (new RepurposeResource($repurpose)) + ->response() + ->setStatusCode(Response::HTTP_CREATED); + } + + public function show(Request $request, Repurpose $repurpose): RepurposeResource + { + $this->authorize('view', $repurpose); + + return new RepurposeResource($repurpose); + } + + public function update(UpdateRepurposeRequest $request, Repurpose $repurpose): RepurposeResource + { + $this->authorize('update', $repurpose); + + return new RepurposeResource(UpdateRepurpose::execute($repurpose, $request->validated())); + } + + public function activate(Request $request, Repurpose $repurpose): RepurposeResource + { + $this->authorize('update', $repurpose); + + return new RepurposeResource(ActivateRepurpose::execute($repurpose)); + } + + public function pause(Request $request, Repurpose $repurpose): RepurposeResource + { + $this->authorize('update', $repurpose); + + return new RepurposeResource(PauseRepurpose::execute($repurpose)); + } + + public function resume(Request $request, Repurpose $repurpose): RepurposeResource + { + $this->authorize('update', $repurpose); + + return new RepurposeResource(ResumeRepurpose::execute($repurpose)); + } + + public function disable(Request $request, Repurpose $repurpose): RepurposeResource + { + $this->authorize('update', $repurpose); + + return new RepurposeResource(DisableRepurpose::execute($repurpose)); + } + + public function destroy(Request $request, Repurpose $repurpose): JsonResponse + { + $this->authorize('delete', $repurpose); + + DeleteRepurpose::execute($repurpose); + + return response()->json(null, Response::HTTP_NO_CONTENT); + } + + public function items(Request $request, Repurpose $repurpose): AnonymousResourceCollection + { + $this->authorize('view', $repurpose); + + return RepurposeItemResource::collection( + ListRepurposeItems::execute($repurpose), + ); + } + + public function sourceFormats(): JsonResponse + { + $this->authorize('viewAny', Repurpose::class); + + return response()->json([ + 'data' => array_map( + fn (SourceFormat $format): array => ['value' => $format->value, 'label' => $format->label()], + SourceFormat::cases(), + ), + ]); + } +} diff --git a/app/Http/Controllers/Api/WebhookController.php b/app/Http/Controllers/Api/WebhookController.php index 71e1f6942..a742a2990 100644 --- a/app/Http/Controllers/Api/WebhookController.php +++ b/app/Http/Controllers/Api/WebhookController.php @@ -104,7 +104,7 @@ public function logs(Request $request, Webhook $webhook): AnonymousResourceColle $logs = $webhook->logs() ->orderByDesc('created_at') - ->paginate(15); + ->paginate((int) config('app.pagination.default')); return WebhookLogResource::collection($logs); } diff --git a/app/Http/Controllers/App/RepurposeController.php b/app/Http/Controllers/App/RepurposeController.php new file mode 100644 index 000000000..fc259e6c9 --- /dev/null +++ b/app/Http/Controllers/App/RepurposeController.php @@ -0,0 +1,231 @@ +authorize('viewAny', Repurpose::class); + + $workspace = $request->user()->currentWorkspace; + $accounts = $this->connectedAccounts($request); + + return Inertia::render('repurposes/Index', [ + 'repurposes' => Inertia::scroll(fn () => RepurposeResource::collection(ListRepurposes::execute($workspace))), + 'sourceAccounts' => SocialAccountResource::collection($this->sourceAccounts($accounts)), + 'destinationAccounts' => SocialAccountResource::collection($accounts), + ]); + } + + public function show(Request $request, Repurpose $repurpose): Response + { + $this->authorize('view', $repurpose); + + $accounts = $this->connectedAccounts($request); + + return Inertia::render('repurposes/Show', [ + 'repurpose' => new RepurposeResource($repurpose->load('sourceAccount')), + 'sourceAccounts' => SocialAccountResource::collection($this->sourceAccounts($accounts)), + 'destinationAccounts' => SocialAccountResource::collection($accounts), + 'items' => Inertia::scroll(fn () => RepurposeItemResource::collection(ListRepurposeItems::execute($repurpose))), + 'sourceFormats' => $this->sourceFormats($repurpose), + 'publishModes' => array_map( + fn (PublishMode $mode): array => [ + 'value' => $mode->value, + 'label' => $mode->label(), + 'description' => $mode->description(), + ], + PublishMode::cases(), + ), + 'recommendedFormats' => $this->recommendedFormats($accounts, $repurpose->source_format), + ...$this->platformSettingsProps($accounts), + ]); + } + + public function store(StoreRepurposeRequest $request): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + $sourceAccountId = (string) $request->validated('source_social_account_id'); + $sourceFormat = SourceFormat::tryFrom((string) $request->validated('source_format')) ?? SourceFormat::Reel; + + $existing = CreateRepurpose::existingFor($workspace, $sourceAccountId, $sourceFormat); + + if ($existing !== null) { + return redirect()->route('app.repurposes.show', $existing); + } + + $repurpose = CreateRepurpose::execute($workspace, $request->user(), $request->validated()); + + return redirect()->route('app.repurposes.show', $repurpose); + } + + public function update(UpdateRepurposeRequest $request, Repurpose $repurpose): RedirectResponse + { + UpdateRepurpose::execute($repurpose, $request->validated()); + + return back(); + } + + public function activate(Request $request, Repurpose $repurpose): RedirectResponse + { + $this->authorize('update', $repurpose); + + ActivateRepurpose::execute($repurpose); + + return back(); + } + + public function pause(Request $request, Repurpose $repurpose): RedirectResponse + { + $this->authorize('update', $repurpose); + + PauseRepurpose::execute($repurpose); + + return back(); + } + + public function resume(Request $request, Repurpose $repurpose): RedirectResponse + { + $this->authorize('update', $repurpose); + + ResumeRepurpose::execute($repurpose); + + return back(); + } + + public function disable(Request $request, Repurpose $repurpose): RedirectResponse + { + $this->authorize('update', $repurpose); + + DisableRepurpose::execute($repurpose); + + return back(); + } + + public function destroy(Request $request, Repurpose $repurpose): RedirectResponse + { + $this->authorize('delete', $repurpose); + + DeleteRepurpose::execute($repurpose); + + return redirect()->route('app.repurposes.index'); + } + + /** + * @return array + */ + private function sourceFormats(Repurpose $repurpose): array + { + $platform = $repurpose->sourceAccount?->platform; + + return array_map( + fn (SourceFormat $format): array => ['value' => $format->value, 'label' => $format->label()], + $platform === null ? [] : SourceFormat::forPlatform($platform), + ); + } + + /** + * @param Collection $accounts + * @return array + */ + private function recommendedFormats(Collection $accounts, SourceFormat $sourceFormat): array + { + return $accounts + ->mapWithKeys(fn (SocialAccount $account): array => [ + $account->id => $sourceFormat->defaultContentTypeFor($account->platform)->value, + ]) + ->all(); + } + + /** + * @param Collection $accounts + * @return array + */ + private function platformSettingsProps(Collection $accounts): array + { + return [ + 'platformConfigs' => fn () => $accounts->mapWithKeys(fn (SocialAccount $account): array => [ + $account->id => new PlatformConfigResource($account), + ]), + 'pinterestBoards' => fn () => $accounts + ->where('platform', Platform::Pinterest) + ->mapWithKeys(fn (SocialAccount $account): array => [ + $account->id => rescue( + fn () => ListPinterestBoards::execute($account), + ['boards' => [], 'truncated' => false], + report: false, + ), + ]), + 'tiktokCreatorInfos' => fn () => $accounts + ->where('platform', Platform::TikTok) + ->mapWithKeys(fn (SocialAccount $account): array => [ + $account->id => rescue( + fn () => app(TikTokCreatorInfo::class)->fetch($account), + null, + report: false, + ), + ]) + ->filter(), + ]; + } + + /** + * @param Collection $accounts + * @return Collection + */ + private function sourceAccounts(Collection $accounts): Collection + { + return $this->usableSourceAccounts($accounts) + ->whereIn('platform', SourceFetcherFactory::supportedPlatforms()) + ->values(); + } + + /** + * @return Collection + */ + private function connectedAccounts(Request $request): Collection + { + return $request->user()->currentWorkspace->socialAccounts()->orderBy('platform')->get(); + } + + /** + * @param Collection $accounts + * @return Collection + */ + private function usableSourceAccounts(Collection $accounts): Collection + { + return $accounts->where('is_active', true)->values(); + } +} diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index a082b0c9e..b63de0409 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(); + $before = $this->repurposeStatesFor($account); + $account->delete(); - session()->flash('flash.banner', __('accounts.flash.disconnected')); - session()->flash('flash.bannerStyle', 'success'); + $this->flashAccountChange('disconnected', $before); return back(); } @@ -89,11 +93,11 @@ public function toggleActive(Request $request, SocialAccount $account): Redirect abort(403); } + $before = $this->repurposeStatesFor($account); + 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', $before); return back(); } @@ -294,4 +298,41 @@ protected function popupCallback(bool $success, string $message, ?string $platfo 'onboardingProgress' => false, ]); } + + /** + * @return Collection + */ + private function repurposeStatesFor(SocialAccount $account): Collection + { + return Repurpose::query() + ->where('workspace_id', $account->workspace_id) + ->get() + ->filter(fn (Repurpose $repurpose): bool => $repurpose->dependsOn($account)) + ->pluck('status', 'id'); + } + + /** + * @param Collection $before + */ + private function flashAccountChange(string $action, Collection $before): void + { + $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', 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/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php b/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php new file mode 100644 index 000000000..ae4d08181 --- /dev/null +++ b/app/Http/Requests/Api/Repurpose/StoreRepurposeRequest.php @@ -0,0 +1,77 @@ +user()->currentWorkspace?->id; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + ...RepurposeRules::settings($this->workspaceId(), sourceRequired: true), + ...RepurposeRules::destinations($this->workspaceId()), + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return RepurposeRules::messages(); + } + + /** + * @return array + */ + public function attributes(): array + { + return RepurposeRules::attributes(); + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + 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', []), + $sourceAccountId, + ); + }); + } +} diff --git a/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php b/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php new file mode 100644 index 000000000..ca2f12402 --- /dev/null +++ b/app/Http/Requests/Api/Repurpose/UpdateRepurposeRequest.php @@ -0,0 +1,94 @@ +user()->currentWorkspace?->id; + } + + private function repurpose(): Repurpose + { + return $this->route('repurpose'); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + ...RepurposeRules::settings($this->workspaceId(), sourceRequired: false), + ...RepurposeRules::destinations($this->workspaceId()), + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return RepurposeRules::messages(); + } + + /** + * @return array + */ + public function attributes(): array + { + return RepurposeRules::attributes(); + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + 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', []), + $sourceAccountId, + ); + + if (! DestinationMetaRules::enforcedFor($this->repurpose())) { + return; + } + + DestinationMetaRules::addRequiredErrors( + $validator, + (array) $this->input('destinations', []), + $this->workspaceId(), + ); + }); + } +} diff --git a/app/Http/Requests/App/Repurpose/StoreRepurposeRequest.php b/app/Http/Requests/App/Repurpose/StoreRepurposeRequest.php new file mode 100644 index 000000000..b32870711 --- /dev/null +++ b/app/Http/Requests/App/Repurpose/StoreRepurposeRequest.php @@ -0,0 +1,41 @@ +user()->can('create', Repurpose::class); + } + + /** + * @return array + */ + public function rules(): array + { + return RepurposeRules::settings($this->user()->current_workspace_id, sourceRequired: true); + } + + /** + * @return array + */ + public function messages(): array + { + return RepurposeRules::messages(); + } + + /** + * @return array + */ + public function attributes(): array + { + return RepurposeRules::attributes(); + } +} diff --git a/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php b/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php new file mode 100644 index 000000000..f5569ce5c --- /dev/null +++ b/app/Http/Requests/App/Repurpose/UpdateRepurposeRequest.php @@ -0,0 +1,94 @@ +user()->can('update', $this->route('repurpose')); + } + + private function workspaceId(): ?string + { + return $this->user()->current_workspace_id; + } + + private function repurpose(): Repurpose + { + return $this->route('repurpose'); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + ...RepurposeRules::settings($this->workspaceId(), sourceRequired: false), + ...RepurposeRules::destinations($this->workspaceId()), + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return RepurposeRules::messages(); + } + + /** + * @return array + */ + public function attributes(): array + { + return RepurposeRules::attributes(); + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + 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', []), + $sourceAccountId, + ); + + if (! DestinationMetaRules::enforcedFor($this->repurpose())) { + return; + } + + DestinationMetaRules::addRequiredErrors( + $validator, + (array) $this->input('destinations', []), + $this->workspaceId(), + ); + }); + } +} diff --git a/app/Http/Resources/Api/RepurposeItemResource.php b/app/Http/Resources/Api/RepurposeItemResource.php new file mode 100644 index 000000000..ec4bd3938 --- /dev/null +++ b/app/Http/Resources/Api/RepurposeItemResource.php @@ -0,0 +1,41 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'repurpose_id' => $this->repurpose_id, + 'source_media_id' => $this->source_media_id, + 'source_permalink' => $this->source_permalink, + 'source_created_at' => $this->source_created_at?->toIso8601String(), + 'status' => $this->status->value, + 'reason' => $this->reason?->value, + 'error' => $this->error, + 'posts' => $this->whenLoaded('posts', fn () => $this->posts->map(fn ($post) => [ + 'id' => $post->id, + 'platforms' => $post->postPlatforms + ->where('enabled', true) + ->map(fn ($postPlatform) => [ + 'platform' => $postPlatform->platform?->value, + 'status' => $postPlatform->status?->value, + ]) + ->filter(fn (array $entry): bool => $entry['platform'] !== null) + ->values(), + ])->values()), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/RepurposeResource.php b/app/Http/Resources/Api/RepurposeResource.php new file mode 100644 index 000000000..6eb359e85 --- /dev/null +++ b/app/Http/Resources/Api/RepurposeResource.php @@ -0,0 +1,35 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'source_social_account_id' => $this->source_social_account_id, + 'source_account' => $this->whenLoaded('sourceAccount', fn () => new SocialAccountResource($this->sourceAccount)), + 'source_format' => $this->source_format->value, + 'publish_mode' => $this->publish_mode->value, + 'destinations' => $this->destinations, + 'status' => $this->status->value, + 'paused_reason' => $this->paused_reason?->value, + 'activated_at' => $this->activated_at?->toIso8601String(), + 'last_polled_at' => $this->last_polled_at?->toIso8601String(), + 'next_poll_at' => $this->next_poll_at?->toIso8601String(), + 'last_error' => $this->last_error, + 'published_items_count' => $this->whenCounted('published_items_count'), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Jobs/Repurpose/PollRepurposeSource.php b/app/Jobs/Repurpose/PollRepurposeSource.php new file mode 100644 index 000000000..32245f560 --- /dev/null +++ b/app/Jobs/Repurpose/PollRepurposeSource.php @@ -0,0 +1,229 @@ +onQueue($account->platform->queue()); + } + + public function uniqueId(): string + { + return $this->account->id; + } + + public function handle(SourceFetcherFactory $fetchers): void + { + $repurposes = $this->activeRepurposes(); + + if ($repurposes->isEmpty()) { + return; + } + + if ($this->account->disconnected_at !== null || $this->account->is_active === false) { + $this->reschedule($repurposes); + + return; + } + + try { + $media = $fetchers->for($this->account)->fetch( + $this->account, + $this->earliestWatermark($repurposes), + $this->watchedFormats($repurposes), + ); + } catch (Throwable $exception) { + $this->recordFailure($repurposes, $exception); + + return; + } + + $publishedByUs = $this->idsPublishedByTryPost($media); + + foreach ($repurposes as $repurpose) { + $this->queueNewMedia($repurpose, $media, $publishedByUs); + } + + $this->markPolled($repurposes); + } + + /** + * @return Collection + */ + private function activeRepurposes(): Collection + { + return Repurpose::query() + ->where('source_social_account_id', $this->account->id) + ->where('status', Status::Active) + ->get(); + } + + /** + * @param Collection $repurposes + * @return array + */ + private function watchedFormats(Collection $repurposes): array + { + return $repurposes->pluck('source_format')->unique()->values()->all(); + } + + /** + * @param Collection $repurposes + */ + private function earliestWatermark(Collection $repurposes): ?CarbonInterface + { + return $repurposes->pluck('activated_at')->filter()->min(); + } + + /** + * @param array $media + * @param array $publishedByUs + */ + private function queueNewMedia(Repurpose $repurpose, array $media, array $publishedByUs): void + { + collect($media) + ->filter(fn (SourceMedia $entry): bool => $entry->format === $repurpose->source_format) + ->reject(fn (SourceMedia $entry): bool => $entry->predates($repurpose->activated_at)) + ->each(fn (SourceMedia $entry) => $this->recordMedia($repurpose, $entry, $publishedByUs)); + } + + /** + * @param array $publishedByUs + */ + private function recordMedia(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; + } + + if (in_array($entry->id, $publishedByUs, true)) { + $item->update(['status' => ItemStatus::Skipped, 'reason' => ItemReason::PublishedViaTrypost]); + + return; + } + + if (blank($entry->downloadUrl)) { + $item->update(['status' => ItemStatus::Skipped, 'reason' => ItemReason::MediaUrlMissing]); + + return; + } + + ProcessRepurposeItem::dispatch($item, (string) $entry->downloadUrl, $entry->caption); + } + + /** + * @param array $media + * @return array + */ + private function idsPublishedByTryPost(array $media): array + { + $ids = array_map(fn (SourceMedia $entry): string => $entry->id, $media); + + if ($ids === []) { + return []; + } + + return PostPlatform::query() + ->whereIn('platform_post_id', $ids) + ->whereHas('post', fn (Builder $query) => $query->where('workspace_id', $this->account->workspace_id)) + ->pluck('platform_post_id') + ->all(); + } + + /** + * @param Collection $repurposes + */ + private function recordFailure(Collection $repurposes, Throwable $exception): void + { + $throttled = $exception instanceof SourceFetchException && $exception->isTransient(); + + $message = Str::limit(TokenRedactor::redact($exception->getMessage()), 1000); + + Repurpose::whereKey($repurposes->modelKeys())->update([ + 'last_error' => $message, + 'last_polled_at' => now(), + 'next_poll_at' => now()->addMinutes($throttled ? $this->backoffMinutes() : $this->intervalMinutes()), + ]); + + Log::error('Repurpose polling failed', [ + 'social_account_id' => $this->account->id, + 'message' => $message, + ]); + } + + /** + * @param Collection $repurposes + */ + private function reschedule(Collection $repurposes): void + { + Repurpose::whereKey($repurposes->modelKeys())->update([ + 'next_poll_at' => now()->addMinutes($this->intervalMinutes()), + ]); + } + + /** + * @param Collection $repurposes + */ + private function markPolled(Collection $repurposes): void + { + Repurpose::whereKey($repurposes->modelKeys())->update([ + 'last_error' => null, + 'last_polled_at' => now(), + 'next_poll_at' => now()->addMinutes($this->intervalMinutes()), + ]); + } + + private function intervalMinutes(): int + { + return (int) config('trypost.repurpose.poll_interval_minutes'); + } + + private function backoffMinutes(): int + { + return (int) config('trypost.repurpose.backoff_minutes'); + } +} diff --git a/app/Jobs/Repurpose/ProcessRepurposeItem.php b/app/Jobs/Repurpose/ProcessRepurposeItem.php new file mode 100644 index 000000000..d46864cb3 --- /dev/null +++ b/app/Jobs/Repurpose/ProcessRepurposeItem.php @@ -0,0 +1,170 @@ + + */ + public function backoff(): array + { + return [60, 300, 900]; + } + + public function uniqueId(): string + { + return $this->item->id; + } + + public function handle(MediaAttacher $media, CaptionAdapter $captions): void + { + if ($this->item->status->isTerminal()) { + return; + } + + $repurpose = $this->item->repurpose; + $workspace = $repurpose->workspace; + $user = $repurpose->user ?? $workspace->owner; + + if ($user === null) { + $this->item->update(['status' => ItemStatus::Failed, 'reason' => ItemReason::PostCreationFailed]); + + return; + } + + if ($this->item->posts()->where('status', '!=', PostStatus::Draft)->exists()) { + $this->item->update(['status' => ItemStatus::Published]); + + return; + } + + $this->item->posts()->each(fn (Post $post) => $post->forceDelete()); + + $this->item->update(['status' => ItemStatus::Processing]); + + $posts = []; + $snapshot = null; + + foreach ($repurpose->destinations as $destination) { + $account = $workspace->socialAccounts()->find(data_get($destination, 'social_account_id')); + + if ($account === null || ! $account->is_active) { + continue; + } + + $post = CreatePost::execute($workspace, $user, [ + 'content' => e($captions->adapt($workspace, $user, $this->caption, $account->platform)), + 'created_via' => CreatedVia::Repurpose, + 'platforms' => [$destination], + ]); + + $post->update(['repurpose_item_id' => $this->item->id]); + + if ($snapshot === null) { + $snapshot = data_get($media->attachFromUrls($post, [['url' => $this->downloadUrl]]), 'attached', []); + + if ($snapshot === []) { + $this->failDownload([...$posts, $post]); + } + } else { + $post->appendMedia($snapshot); + } + + $posts[] = $post; + } + + if ($posts === []) { + $this->item->update(['status' => ItemStatus::Failed, 'reason' => ItemReason::NoUsableDestinations]); + + return; + } + + if ($repurpose->publish_mode === PublishMode::Draft) { + $this->item->update(['status' => ItemStatus::Drafted, 'reason' => null, 'error' => null]); + + return; + } + + DB::transaction(function () use ($posts): void { + foreach ($posts as $post) { + $post->update(['status' => PostStatus::Scheduled, 'scheduled_at' => now()]); + } + }); + + $this->item->update(['status' => ItemStatus::Published, 'reason' => null, 'error' => null]); + } + + public function failed(Throwable $exception): void + { + if ($this->item->repurpose?->publish_mode !== PublishMode::Draft) { + $this->item->posts() + ->where('status', PostStatus::Draft) + ->get() + ->each(fn (Post $post) => $post->forceDelete()); + } + + $this->item->update([ + 'status' => ItemStatus::Failed, + 'reason' => $exception instanceof SourceDownloadException ? ItemReason::DownloadFailed : $this->item->reason, + 'error' => $this->safeError($exception), + ]); + } + + private function safeError(Throwable $exception): string + { + $message = str_replace($this->downloadUrl, '[source url]', $exception->getMessage()); + + return Str::limit((string) TokenRedactor::redact($message), 1000); + } + + /** + * @param array $posts + */ + private function failDownload(array $posts): never + { + foreach ($posts as $post) { + $post->forceDelete(); + } + + throw new SourceDownloadException("Could not download the source video for repurpose item {$this->item->id}."); + } +} diff --git a/app/Mcp/Concerns/ResolvesWorkspaceRepurpose.php b/app/Mcp/Concerns/ResolvesWorkspaceRepurpose.php new file mode 100644 index 000000000..c0ab9c73d --- /dev/null +++ b/app/Mcp/Concerns/ResolvesWorkspaceRepurpose.php @@ -0,0 +1,26 @@ +where('workspace_id', $workspace->id) + ->find($id); + + if (! $repurpose instanceof Repurpose) { + return Response::error('Repurpose not found.'); + } + + return $repurpose; + } +} diff --git a/app/Mcp/Requests/Repurpose/CreateRepurposeRequest.php b/app/Mcp/Requests/Repurpose/CreateRepurposeRequest.php new file mode 100644 index 000000000..72e673b9e --- /dev/null +++ b/app/Mcp/Requests/Repurpose/CreateRepurposeRequest.php @@ -0,0 +1,21 @@ + + */ + public static function rules(?string $workspaceId = null): array + { + return [ + ...RepurposeRules::settings($workspaceId, sourceRequired: true), + ...RepurposeRules::destinations($workspaceId), + ]; + } +} diff --git a/app/Mcp/Requests/Repurpose/ListRepurposeItemsRequest.php b/app/Mcp/Requests/Repurpose/ListRepurposeItemsRequest.php new file mode 100644 index 000000000..7b913b91e --- /dev/null +++ b/app/Mcp/Requests/Repurpose/ListRepurposeItemsRequest.php @@ -0,0 +1,19 @@ + + */ + public static function rules(): array + { + return [ + 'repurpose_id' => ['required', 'string', 'uuid'], + 'page' => ['sometimes', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Mcp/Requests/Repurpose/ListRepurposesRequest.php b/app/Mcp/Requests/Repurpose/ListRepurposesRequest.php new file mode 100644 index 000000000..38299d98f --- /dev/null +++ b/app/Mcp/Requests/Repurpose/ListRepurposesRequest.php @@ -0,0 +1,18 @@ + + */ + public static function rules(): array + { + return [ + 'page' => ['sometimes', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Mcp/Requests/Repurpose/RepurposeIdRequest.php b/app/Mcp/Requests/Repurpose/RepurposeIdRequest.php new file mode 100644 index 000000000..049d2cf44 --- /dev/null +++ b/app/Mcp/Requests/Repurpose/RepurposeIdRequest.php @@ -0,0 +1,18 @@ + + */ + public static function rules(): array + { + return [ + 'repurpose_id' => ['required', 'string', 'uuid'], + ]; + } +} diff --git a/app/Mcp/Requests/Repurpose/UpdateRepurposeRequest.php b/app/Mcp/Requests/Repurpose/UpdateRepurposeRequest.php new file mode 100644 index 000000000..3aa87f98a --- /dev/null +++ b/app/Mcp/Requests/Repurpose/UpdateRepurposeRequest.php @@ -0,0 +1,22 @@ + + */ + public static function rules(?string $workspaceId = null): array + { + return [ + 'repurpose_id' => ['required', 'string', 'uuid'], + ...RepurposeRules::settings($workspaceId, sourceRequired: false), + ...RepurposeRules::destinations($workspaceId), + ]; + } +} diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php index e2b0bffbd..d20b18bdc 100644 --- a/app/Mcp/Servers/TryPostServer.php +++ b/app/Mcp/Servers/TryPostServer.php @@ -26,6 +26,17 @@ use App\Mcp\Tools\Post\PublishPostTool; use App\Mcp\Tools\Post\RequestMediaUploadTool; use App\Mcp\Tools\Post\UpdatePostTool; +use App\Mcp\Tools\Repurpose\ActivateRepurposeTool; +use App\Mcp\Tools\Repurpose\CreateRepurposeTool; +use App\Mcp\Tools\Repurpose\DeleteRepurposeTool; +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\PauseRepurposeTool; +use App\Mcp\Tools\Repurpose\ResumeRepurposeTool; +use App\Mcp\Tools\Repurpose\UpdateRepurposeTool; use App\Mcp\Tools\Signature\CreateSignatureTool; use App\Mcp\Tools\Signature\DeleteSignatureTool; use App\Mcp\Tools\Signature\ListSignaturesTool; @@ -53,7 +64,7 @@ #[Name('TryPost')] #[Version('1.0.0')] #[Icon('images/trypost/icon.png', mimeType: 'image/png')] -#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, the Asset Library, signatures, labels, social accounts, workspaces, outgoing webhooks, and API keys.')] +#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, the Asset Library, signatures, labels, social accounts, workspaces, outgoing webhooks, repurposes (auto-replicating videos posted outside TryPost), and API keys.')] class TryPostServer extends Server { public int $defaultPaginationLength = 100; @@ -97,6 +108,17 @@ class TryPostServer extends Server ListPinterestBoardsTool::class, ListDiscordChannelsTool::class, ToggleSocialAccountTool::class, + ListRepurposesTool::class, + CreateRepurposeTool::class, + GetRepurposeTool::class, + UpdateRepurposeTool::class, + ActivateRepurposeTool::class, + PauseRepurposeTool::class, + ResumeRepurposeTool::class, + DisableRepurposeTool::class, + ListRepurposeItemsTool::class, + ListRepurposeSourceFormatsTool::class, + DeleteRepurposeTool::class, // Webhooks ListWebhooksTool::class, diff --git a/app/Mcp/Tools/Repurpose/ActivateRepurposeTool.php b/app/Mcp/Tools/Repurpose/ActivateRepurposeTool.php new file mode 100644 index 000000000..f11077959 --- /dev/null +++ b/app/Mcp/Tools/Repurpose/ActivateRepurposeTool.php @@ -0,0 +1,60 @@ +authorizeCurrentWorkspace($request, 'manageRepurposes', 'Not authorized to manage repurposes.'); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(RepurposeIdRequest::rules()); + $repurpose = $this->repurposeInWorkspace($workspace, $validated['repurpose_id']); + + if (! $repurpose instanceof Repurpose) { + return $repurpose; + } + + try { + $repurpose = ActivateRepurpose::execute($repurpose); + } catch (ValidationException $e) { + return Response::error($e->getMessage()); + } + + return Response::structured((new RepurposeResource($repurpose))->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'repurpose_id' => $schema->string()->required()->description('The repurpose to activate.'), + ]; + } +} diff --git a/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php b/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php new file mode 100644 index 000000000..f51b30396 --- /dev/null +++ b/app/Mcp/Tools/Repurpose/CreateRepurposeTool.php @@ -0,0 +1,70 @@ +authorizeCurrentWorkspace($request, 'manageRepurposes', 'Not authorized to manage repurposes.'); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(CreateRepurposeRequest::rules($workspace->id)); + + 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'), + ); + + try { + $repurpose = CreateRepurpose::execute($workspace, $request->user(), $validated); + } catch (ValidationException $e) { + return Response::error($e->getMessage()); + } + + return Response::structured((new RepurposeResource($repurpose))->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'source_social_account_id' => $schema->string()->required()->description('Instagram or Facebook account to watch.'), + 'source_format' => $schema->string()->description('Which video format to watch: reel, video or story. Defaults to reel.'), + 'publish_mode' => $schema->string()->description('publish to schedule each replicated video straight away, or draft to leave it in TryPost for review. Defaults to publish.'), + 'destinations' => $schema->array()->description('Accounts to republish to, each with a content_type that accepts video and optional per-platform meta.'), + ]; + } +} diff --git a/app/Mcp/Tools/Repurpose/DeleteRepurposeTool.php b/app/Mcp/Tools/Repurpose/DeleteRepurposeTool.php new file mode 100644 index 000000000..2adb334ec --- /dev/null +++ b/app/Mcp/Tools/Repurpose/DeleteRepurposeTool.php @@ -0,0 +1,54 @@ +authorizeCurrentWorkspace($request, 'manageRepurposes', 'Not authorized to manage repurposes.'); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(RepurposeIdRequest::rules()); + $repurpose = $this->repurposeInWorkspace($workspace, $validated['repurpose_id']); + + if (! $repurpose instanceof Repurpose) { + return $repurpose; + } + + DeleteRepurpose::execute($repurpose); + + return Response::structured(['deleted' => true]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'repurpose_id' => $schema->string()->required()->description('The repurpose to delete.'), + ]; + } +} diff --git a/app/Mcp/Tools/Repurpose/DisableRepurposeTool.php b/app/Mcp/Tools/Repurpose/DisableRepurposeTool.php new file mode 100644 index 000000000..7f94b2bc5 --- /dev/null +++ b/app/Mcp/Tools/Repurpose/DisableRepurposeTool.php @@ -0,0 +1,60 @@ +authorizeCurrentWorkspace($request, 'manageRepurposes', 'Not authorized to manage repurposes.'); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(RepurposeIdRequest::rules()); + $repurpose = $this->repurposeInWorkspace($workspace, $validated['repurpose_id']); + + if (! $repurpose instanceof Repurpose) { + return $repurpose; + } + + try { + $repurpose = DisableRepurpose::execute($repurpose); + } catch (ValidationException $e) { + return Response::error($e->getMessage()); + } + + return Response::structured((new RepurposeResource($repurpose))->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'repurpose_id' => $schema->string()->required()->description('The repurpose to disable.'), + ]; + } +} diff --git a/app/Mcp/Tools/Repurpose/GetRepurposeTool.php b/app/Mcp/Tools/Repurpose/GetRepurposeTool.php new file mode 100644 index 000000000..3e675e30b --- /dev/null +++ b/app/Mcp/Tools/Repurpose/GetRepurposeTool.php @@ -0,0 +1,54 @@ +authorizeCurrentWorkspace($request, 'manageRepurposes', 'Not authorized to manage repurposes.'); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(RepurposeIdRequest::rules()); + $repurpose = $this->repurposeInWorkspace($workspace, $validated['repurpose_id']); + + if (! $repurpose instanceof Repurpose) { + return $repurpose; + } + + return Response::structured((new RepurposeResource($repurpose))->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'repurpose_id' => $schema->string()->required()->description('The repurpose to read.'), + ]; + } +} diff --git a/app/Mcp/Tools/Repurpose/ListRepurposeItemsTool.php b/app/Mcp/Tools/Repurpose/ListRepurposeItemsTool.php new file mode 100644 index 000000000..754c3c947 --- /dev/null +++ b/app/Mcp/Tools/Repurpose/ListRepurposeItemsTool.php @@ -0,0 +1,64 @@ +authorizeCurrentWorkspace($request, 'manageRepurposes', 'Not authorized to manage repurposes.'); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(ListRepurposeItemsRequest::rules()); + $repurpose = $this->repurposeInWorkspace($workspace, $validated['repurpose_id']); + + if (! $repurpose instanceof Repurpose) { + return $repurpose; + } + + $items = ListRepurposeItems::execute($repurpose, page: (int) data_get($validated, 'page', 1)); + + return Response::structured([ + 'items' => RepurposeItemResource::collection($items->items())->resolve(), + 'total' => $items->total(), + 'per_page' => $items->perPage(), + 'current_page' => $items->currentPage(), + 'last_page' => $items->lastPage(), + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'repurpose_id' => $schema->string()->required()->description('The repurpose whose activity to read.'), + 'page' => $schema->integer()->description('Page number.'), + ]; + } +} diff --git a/app/Mcp/Tools/Repurpose/ListRepurposeSourceFormatsTool.php b/app/Mcp/Tools/Repurpose/ListRepurposeSourceFormatsTool.php new file mode 100644 index 000000000..c41582c83 --- /dev/null +++ b/app/Mcp/Tools/Repurpose/ListRepurposeSourceFormatsTool.php @@ -0,0 +1,38 @@ +authorizeCurrentWorkspace($request, 'manageRepurposes', 'Not authorized to manage repurposes.'); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + return Response::structured([ + 'source_formats' => array_map( + fn (SourceFormat $format): array => ['value' => $format->value, 'label' => $format->label()], + SourceFormat::cases(), + ), + ]); + } +} diff --git a/app/Mcp/Tools/Repurpose/ListRepurposesTool.php b/app/Mcp/Tools/Repurpose/ListRepurposesTool.php new file mode 100644 index 000000000..69a80a9c8 --- /dev/null +++ b/app/Mcp/Tools/Repurpose/ListRepurposesTool.php @@ -0,0 +1,56 @@ +authorizeCurrentWorkspace($request, 'manageRepurposes', 'Not authorized to manage repurposes.'); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(ListRepurposesRequest::rules()); + + $repurposes = ListRepurposes::execute($workspace, (int) data_get($validated, 'page', 1)); + + return Response::structured([ + 'repurposes' => RepurposeResource::collection($repurposes->items())->resolve(), + 'total' => $repurposes->total(), + 'per_page' => $repurposes->perPage(), + 'current_page' => $repurposes->currentPage(), + 'last_page' => $repurposes->lastPage(), + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'page' => $schema->integer()->description('Page number.'), + ]; + } +} diff --git a/app/Mcp/Tools/Repurpose/PauseRepurposeTool.php b/app/Mcp/Tools/Repurpose/PauseRepurposeTool.php new file mode 100644 index 000000000..1ccb242ff --- /dev/null +++ b/app/Mcp/Tools/Repurpose/PauseRepurposeTool.php @@ -0,0 +1,60 @@ +authorizeCurrentWorkspace($request, 'manageRepurposes', 'Not authorized to manage repurposes.'); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(RepurposeIdRequest::rules()); + $repurpose = $this->repurposeInWorkspace($workspace, $validated['repurpose_id']); + + if (! $repurpose instanceof Repurpose) { + return $repurpose; + } + + try { + $repurpose = PauseRepurpose::execute($repurpose); + } catch (ValidationException $e) { + return Response::error($e->getMessage()); + } + + return Response::structured((new RepurposeResource($repurpose))->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'repurpose_id' => $schema->string()->required()->description('The repurpose to pause.'), + ]; + } +} diff --git a/app/Mcp/Tools/Repurpose/ResumeRepurposeTool.php b/app/Mcp/Tools/Repurpose/ResumeRepurposeTool.php new file mode 100644 index 000000000..82308f4fa --- /dev/null +++ b/app/Mcp/Tools/Repurpose/ResumeRepurposeTool.php @@ -0,0 +1,60 @@ +authorizeCurrentWorkspace($request, 'manageRepurposes', 'Not authorized to manage repurposes.'); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate(RepurposeIdRequest::rules()); + $repurpose = $this->repurposeInWorkspace($workspace, $validated['repurpose_id']); + + if (! $repurpose instanceof Repurpose) { + return $repurpose; + } + + try { + $repurpose = ResumeRepurpose::execute($repurpose); + } catch (ValidationException $e) { + return Response::error($e->getMessage()); + } + + return Response::structured((new RepurposeResource($repurpose))->resolve()); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'repurpose_id' => $schema->string()->required()->description('The repurpose to resume.'), + ]; + } +} diff --git a/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php b/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php new file mode 100644 index 000000000..79c4fef69 --- /dev/null +++ b/app/Mcp/Tools/Repurpose/UpdateRepurposeTool.php @@ -0,0 +1,85 @@ +authorizeCurrentWorkspace($request, 'manageRepurposes', 'Not authorized to manage repurposes.'); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $identified = $request->validate(RepurposeIdRequest::rules()); + $repurpose = $this->repurposeInWorkspace($workspace, $identified['repurpose_id']); + + if (! $repurpose instanceof Repurpose) { + return $repurpose; + } + + $validated = $request->validate(UpdateRepurposeRequest::rules($workspace->id)); + + 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), + ); + + if (DestinationMetaRules::enforcedFor($repurpose)) { + DestinationMetaRules::assertRequired( + (array) data_get($validated, 'destinations', []), + $workspace->id, + ); + } + + return Response::structured( + (new RepurposeResource(UpdateRepurpose::execute($repurpose, $validated)))->resolve(), + ); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'repurpose_id' => $schema->string()->required()->description('The repurpose to update.'), + 'source_social_account_id' => $schema->string()->description('Move the repurpose to another source account.'), + 'source_format' => $schema->string()->description('Which video format to watch: reel, video or story.'), + 'publish_mode' => $schema->string()->description('publish to schedule each replicated video straight away, or draft to leave it in TryPost for review.'), + 'destinations' => $schema->array()->description('Replaces the destination list.'), + ]; + } +} diff --git a/app/Models/Post.php b/app/Models/Post.php index 4c1d764b4..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; @@ -36,6 +36,7 @@ class Post extends Model 'media', 'status', 'created_via', + 'repurpose_item_id', 'scheduled_at', 'published_at', ]; diff --git a/app/Models/Repurpose.php b/app/Models/Repurpose.php new file mode 100644 index 000000000..c82b9e282 --- /dev/null +++ b/app/Models/Repurpose.php @@ -0,0 +1,85 @@ + '[]', + ]; + + protected function casts(): array + { + return [ + 'destinations' => 'array', + 'source_format' => SourceFormat::class, + 'publish_mode' => PublishMode::class, + 'status' => Status::class, + 'paused_reason' => PauseReason::class, + 'activated_at' => 'datetime', + 'last_polled_at' => 'datetime', + 'next_poll_at' => 'datetime', + ]; + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function sourceAccount(): BelongsTo + { + return $this->belongsTo(SocialAccount::class, 'source_social_account_id'); + } + + 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/Models/RepurposeItem.php b/app/Models/RepurposeItem.php new file mode 100644 index 000000000..171f32815 --- /dev/null +++ b/app/Models/RepurposeItem.php @@ -0,0 +1,47 @@ + ItemStatus::class, + 'reason' => ItemReason::class, + 'source_created_at' => 'datetime', + ]; + } + + public function repurpose(): BelongsTo + { + return $this->belongsTo(Repurpose::class); + } + + public function posts(): HasMany + { + return $this->hasMany(Post::class); + } +} diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 6059bb3ed..f50a2d5a2 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -58,6 +58,7 @@ class SocialAccount extends Model protected $hidden = [ 'access_token', 'refresh_token', + 'meta', ]; protected $appends = [ diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index cb43f9aaf..33a05031c 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -97,6 +97,11 @@ public function webhooks(): HasMany return $this->hasMany(Webhook::class); } + public function repurposes(): HasMany + { + return $this->hasMany(Repurpose::class); + } + /** * Get invites for this workspace (invites from the same account that include this workspace). * diff --git a/app/Observers/SocialAccountObserver.php b/app/Observers/SocialAccountObserver.php index c306a2a21..df790ecfe 100644 --- a/app/Observers/SocialAccountObserver.php +++ b/app/Observers/SocialAccountObserver.php @@ -12,6 +12,7 @@ use App\Jobs\PostHog\SyncAccountUsage; use App\Models\SocialAccount; use App\Services\PostHogService; +use App\Services\Repurpose\RepurposeAccountSync; class SocialAccountObserver { @@ -43,8 +44,15 @@ public function deleted(SocialAccount $socialAccount): void $this->syncUsageAndOnboarding($socialAccount); } + public function deleting(SocialAccount $socialAccount): void + { + app(RepurposeAccountSync::class)->accountRemoved($socialAccount); + } + public function updated(SocialAccount $socialAccount): void { + app(RepurposeAccountSync::class)->accountChanged($socialAccount); + if (! $socialAccount->wasChanged('status')) { return; } diff --git a/app/Policies/RepurposePolicy.php b/app/Policies/RepurposePolicy.php new file mode 100644 index 000000000..f23e43418 --- /dev/null +++ b/app/Policies/RepurposePolicy.php @@ -0,0 +1,38 @@ +currentWorkspace !== null + && $user->can('manageRepurposes', $user->currentWorkspace); + } + + public function view(User $user, Repurpose $repurpose): bool + { + return $repurpose->workspace_id === $user->current_workspace_id + && $user->can('manageRepurposes', $user->currentWorkspace); + } + + public function create(User $user): bool + { + return $this->viewAny($user); + } + + public function update(User $user, Repurpose $repurpose): bool + { + return $this->view($user, $repurpose); + } + + public function delete(User $user, Repurpose $repurpose): bool + { + return $this->view($user, $repurpose); + } +} diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index 6de177818..107871649 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -61,6 +61,11 @@ public function manageWebhooks(User $user, Workspace $workspace): bool return $this->isOwnerOrWorkspaceAdmin($user, $workspace); } + public function manageRepurposes(User $user, Workspace $workspace): bool + { + return $this->createPost($user, $workspace); + } + public function createPost(User $user, Workspace $workspace): bool { if ($this->isOwner($user, $workspace)) { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9bcef4afe..9114a0cee 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -16,6 +16,8 @@ use App\Models\Post; use App\Models\PostComment; use App\Models\PostPlatform; +use App\Models\Repurpose; +use App\Models\RepurposeItem; use App\Models\SocialAccount; use App\Models\Subscription; use App\Models\SubscriptionItem; @@ -101,6 +103,8 @@ protected function configureMorphMap(): void 'notificationPreference' => NotificationPreference::class, 'plan' => Plan::class, 'post' => Post::class, + 'repurpose' => Repurpose::class, + 'repurposeItem' => RepurposeItem::class, 'postComment' => PostComment::class, 'postPlatform' => PostPlatform::class, 'socialAccount' => SocialAccount::class, diff --git a/app/Services/Repurpose/CaptionAdapter.php b/app/Services/Repurpose/CaptionAdapter.php new file mode 100644 index 000000000..a3e1ca575 --- /dev/null +++ b/app/Services/Repurpose/CaptionAdapter.php @@ -0,0 +1,91 @@ + */ + private array $shortened = []; + + public function __construct(private readonly ContentSanitizer $sanitizer) {} + + public function adapt(Workspace $workspace, ?User $user, string $caption, Platform $platform): string + { + if ($this->fits($caption, $platform)) { + return $caption; + } + + return $this->shorten($workspace, $user, $caption, $platform) + ?? $this->truncate($caption, $platform); + } + + private function sent(string $caption, Platform $platform): string + { + return $this->sanitizer->displayText($caption, $platform); + } + + private function fits(string $caption, Platform $platform): bool + { + return $platform->contentOverflow($this->sent($caption, $platform)) === 0; + } + + private function shorten(Workspace $workspace, ?User $user, string $caption, Platform $platform): ?string + { + if ($user === null || Gate::forUser($user)->denies('useAi', $workspace->account)) { + return null; + } + + $key = $platform->maxContentLength().':'.md5($caption); + + $shortened = $this->shortened[$key] ??= rescue( + fn (): string => $this->ask($workspace, $user, $caption, $platform), + ); + + return filled($shortened) && $this->fits($shortened, $platform) ? $shortened : null; + } + + 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 + { + $candidate = $caption; + + while (! $this->fits($candidate, $platform) && str_contains($candidate, ' ')) { + $candidate = rtrim(Str::beforeLast($candidate, ' ')); + } + + return $this->fits($candidate, $platform) + ? $candidate + : Str::limit($caption, $platform->maxContentLength(), ''); + } +} diff --git a/app/Services/Repurpose/FacebookSourceFetcher.php b/app/Services/Repurpose/FacebookSourceFetcher.php new file mode 100644 index 000000000..b69381e51 --- /dev/null +++ b/app/Services/Repurpose/FacebookSourceFetcher.php @@ -0,0 +1,145 @@ + $formats + * @return array + */ + public function fetch(SocialAccount $account, ?CarbonInterface $since, array $formats): array + { + $wantsReels = in_array(SourceFormat::Reel, $formats, true); + $wantsVideos = in_array(SourceFormat::Video, $formats, true); + + $reels = $wantsReels || $wantsVideos + ? $this->videos($account, 'video_reels', $since, SourceFormat::Reel) + : []; + + $videos = $wantsVideos + ? $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); + + return array_values(array_filter( + $videos, + fn (SourceMedia $media): bool => ! in_array($media->id, $reelIds, true), + )); + } + + /** + * @return array + */ + private function videos(SocialAccount $account, string $edge, ?CarbonInterface $since, SourceFormat $format): array + { + $rows = $this->rowsWithFallback( + $account, + "{$this->graphApi()}/{$account->platform_user_id}/{$edge}", + ['fields' => self::VIDEO_FIELDS, 'limit' => self::PAGE_SIZE, 'since' => $since?->getTimestamp()], + self::PUBLIC_VIDEO_FIELDS, + ); + + return array_map( + fn (array $row): SourceMedia => new SourceMedia( + id: (string) data_get($row, 'id'), + format: $format, + downloadUrl: data_get($row, 'source'), + caption: (string) data_get($row, 'description', ''), + permalink: data_get($row, 'permalink_url'), + createdAt: $this->timestamp($row, 'created_time'), + ), + $rows, + ); + } + + /** + * @return array + */ + private function stories(SocialAccount $account, ?CarbonInterface $since): array + { + $rows = $this->rows($account, "{$this->graphApi()}/{$account->platform_user_id}/stories", [ + 'fields' => 'post_id,status,creation_time,media_type,media_id,url', + 'limit' => self::PAGE_SIZE, + 'since' => $since?->getTimestamp(), + ]); + + return collect($rows) + ->filter(fn (array $row): bool => $this->isPublishedVideo($row)) + ->map(fn (array $row): SourceMedia => $this->toStory($account, $row)) + ->values() + ->all(); + } + + /** + * @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; + } + + /** + * @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 + { + if ($videoId === '') { + return null; + } + + $response = $this->http($account)->get("{$this->graphApi()}/{$videoId}", ['fields' => 'source']); + + return $response->successful() ? $response->json('source') : null; + } + + private function graphApi(): string + { + return config('trypost.platforms.facebook.graph_api'); + } +} diff --git a/app/Services/Repurpose/InstagramSourceFetcher.php b/app/Services/Repurpose/InstagramSourceFetcher.php new file mode 100644 index 000000000..7a86f7f2c --- /dev/null +++ b/app/Services/Repurpose/InstagramSourceFetcher.php @@ -0,0 +1,102 @@ + $formats + * @return array + */ + public function fetch(SocialAccount $account, ?CarbonInterface $since, array $formats): array + { + $media = []; + + if (in_array(SourceFormat::Reel, $formats, true) || in_array(SourceFormat::Video, $formats, true)) { + $media = array_map( + fn (array $row): SourceMedia => $this->toSourceMedia($row, null), + $this->request($account, 'media', $since), + ); + } + + if (in_array(SourceFormat::Story, $formats, true)) { + $media = [...$media, ...array_map( + fn (array $row): SourceMedia => $this->toSourceMedia($row, SourceFormat::Story), + $this->request($account, 'stories', null), + )]; + } + + return $media; + } + + /** + * @return array> + */ + private function request(SocialAccount $account, string $edge, ?CarbonInterface $since): array + { + return $this->rowsWithFallback( + $account, + "{$this->graphApi($account)}/{$account->platform_user_id}/{$edge}", + ['fields' => self::FIELDS, 'limit' => self::PAGE_SIZE, 'since' => $since?->getTimestamp()], + self::PUBLIC_FIELDS, + ); + } + + /** + * @param array $row + */ + private function toSourceMedia(array $row, ?SourceFormat $edgeFormat): SourceMedia + { + return new SourceMedia( + id: (string) data_get($row, 'id'), + format: $this->format($row, $edgeFormat), + downloadUrl: data_get($row, 'media_url'), + caption: (string) data_get($row, 'caption', ''), + permalink: data_get($row, 'permalink'), + createdAt: $this->timestamp($row, 'timestamp'), + ); + } + + /** + * @param array $row + */ + private function format(array $row, ?SourceFormat $edgeFormat): ?SourceFormat + { + if (MediaType::tryFrom((string) data_get($row, 'media_type')) !== MediaType::Video) { + return null; + } + + if ($edgeFormat !== null) { + return $edgeFormat; + } + + return match (MediaProductType::tryFrom((string) data_get($row, 'media_product_type'))) { + MediaProductType::Feed => SourceFormat::Video, + MediaProductType::Story => SourceFormat::Story, + default => SourceFormat::Reel, + }; + } + + private function graphApi(SocialAccount $account): string + { + return $account->platform === Platform::InstagramFacebook + ? config('trypost.platforms.instagram-facebook.graph_api') + : config('trypost.platforms.instagram.graph_api'); + } +} diff --git a/app/Services/Repurpose/MetaSourceFetcher.php b/app/Services/Repurpose/MetaSourceFetcher.php new file mode 100644 index 000000000..ca08b9ab5 --- /dev/null +++ b/app/Services/Repurpose/MetaSourceFetcher.php @@ -0,0 +1,62 @@ +withToken($account->access_token); + } + + /** + * @param array $query + * @return array> + */ + protected function rowsWithFallback(SocialAccount $account, string $url, array $query, string $fallbackFields): array + { + try { + return $this->rows($account, $url, $query); + } catch (SourceFetchException $exception) { + if (! $exception->isUnknownField()) { + throw $exception; + } + } + + 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> + */ + protected function rows(SocialAccount $account, string $url, array $query): array + { + $response = $this->http($account)->get($url, array_filter($query)); + + if ($response->failed()) { + throw new SourceFetchException($response); + } + + return (array) $response->json('data', []); + } +} diff --git a/app/Services/Repurpose/RepurposeAccountSync.php b/app/Services/Repurpose/RepurposeAccountSync.php new file mode 100644 index 000000000..d0d871dde --- /dev/null +++ b/app/Services/Repurpose/RepurposeAccountSync.php @@ -0,0 +1,179 @@ + */ + private const WATCHED_ATTRIBUTES = ['status', 'is_active', 'platform']; + + public function accountRemoved(SocialAccount $account): void + { + $this->guard(function () use ($account): void { + foreach ($this->sourcedBy($account) as $repurpose) { + $this->pause($repurpose, PauseReason::SourceRemoved); + } + + $this->pruneDestination($account); + }, $account); + } + + 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); + } + + if ($this->isUsable($account)) { + $this->resumeRecovered($account); + + return; + } + + foreach ($this->sourcedBy($account) as $repurpose) { + $this->pause($repurpose, PauseReason::SourceUnavailable); + } + }, $account); + } + + private function isUsable(SocialAccount $account): bool + { + return SocialAccount::query() + ->whereKey($account->id) + ->where('is_active', true) + ->where('status', AccountStatus::Connected) + ->exists(); + } + + /** + * @return Collection + */ + private function sourcedBy(SocialAccount $account): Collection + { + return Repurpose::query() + ->where('source_social_account_id', $account->id) + ->where('status', Status::Active) + ->get(); + } + + private function resumeRecovered(SocialAccount $account): void + { + $candidates = Repurpose::query() + ->where('source_social_account_id', $account->id) + ->where('status', Status::Paused) + ->where('paused_reason', PauseReason::SourceUnavailable) + ->get(); + + foreach ($candidates as $repurpose) { + try { + ActivateRepurpose::assertSourceUsable($repurpose); + ActivateRepurpose::assertHasUsableDestination($repurpose); + ActivateRepurpose::assertDestinationsCarryRequiredMeta($repurpose); + } catch (ValidationException) { + continue; + } + + ResumeRepurpose::execute($repurpose); + } + } + + private function pruneDestination(SocialAccount $account): void + { + foreach ($this->destinedFor($account) as $repurpose) { + $remaining = array_values(array_filter( + $repurpose->destinations, + fn (array $destination): bool => data_get($destination, 'social_account_id') !== $account->id, + )); + + $repurpose->update(['destinations' => $remaining]); + + if ($remaining === []) { + $this->pause($repurpose, PauseReason::NoDestinations); + } + } + } + + private function realignDestinations(SocialAccount $account): void + { + $supported = array_map( + fn (ContentType $contentType): string => $contentType->value, + ContentType::forPlatform($account->platform), + ); + + foreach ($this->destinedFor($account) as $repurpose) { + $destinations = array_map(function (array $destination) use ($account, $supported): array { + if (data_get($destination, 'social_account_id') !== $account->id) { + return $destination; + } + + if (in_array(data_get($destination, 'content_type'), $supported, true)) { + return $destination; + } + + $destination['content_type'] = ContentType::defaultFor($account->platform)->value; + + return $destination; + }, $repurpose->destinations); + + $repurpose->update(['destinations' => array_values($destinations)]); + } + } + + /** + * @return SupportCollection + */ + private function destinedFor(SocialAccount $account): SupportCollection + { + return Repurpose::query() + ->where('workspace_id', $account->workspace_id) + ->get() + ->filter(fn (Repurpose $repurpose): bool => $repurpose->hasDestination($account->id)) + ->values(); + } + + private function pause(Repurpose $repurpose, PauseReason $reason): void + { + RepurposeTransition::applyIfPossible( + $repurpose, + [Status::Active], + fn (Repurpose $locked) => $locked->update([ + 'status' => Status::Paused, + 'paused_reason' => $reason, + ]), + ); + } + + private function guard(callable $work, SocialAccount $account): void + { + try { + $work(); + } catch (Throwable $exception) { + Log::error('Repurpose account sync failed', [ + 'social_account_id' => $account->id, + 'message' => $exception->getMessage(), + ]); + } + } +} diff --git a/app/Services/Repurpose/SourceFetcher.php b/app/Services/Repurpose/SourceFetcher.php new file mode 100644 index 000000000..d2466f384 --- /dev/null +++ b/app/Services/Repurpose/SourceFetcher.php @@ -0,0 +1,19 @@ + $formats + * @return array + */ + public function fetch(SocialAccount $account, ?CarbonInterface $since, array $formats): array; +} diff --git a/app/Services/Repurpose/SourceFetcherFactory.php b/app/Services/Repurpose/SourceFetcherFactory.php new file mode 100644 index 000000000..4f782fc83 --- /dev/null +++ b/app/Services/Repurpose/SourceFetcherFactory.php @@ -0,0 +1,29 @@ +platform) { + Platform::Instagram, Platform::InstagramFacebook => app(InstagramSourceFetcher::class), + Platform::Facebook => app(FacebookSourceFetcher::class), + default => throw new InvalidArgumentException("{$account->platform->value} cannot be a repurpose source."), + }; + } + + /** + * @return array + */ + public static function supportedPlatforms(): array + { + return [Platform::Instagram, Platform::InstagramFacebook, Platform::Facebook]; + } +} 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/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index f3f6e4438..a12b60a36 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -209,13 +209,13 @@ private function buildTitle(string $content): string { $maxLength = 100; $shortsTag = ' #Shorts'; - $availableLength = $maxLength - strlen($shortsTag); + $availableLength = $maxLength - mb_strlen($shortsTag); $firstLine = explode("\n", $content)[0]; $title = explode('.', $firstLine)[0]; - if (strlen($title) > $availableLength) { - $title = substr($title, 0, $availableLength - 3).'...'; + if (mb_strlen($title) > $availableLength) { + $title = mb_substr($title, 0, $availableLength - 3).'...'; } return $title.$shortsTag; 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/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index 15cc71046..cec61ee93 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -160,7 +160,7 @@ public static function assertStoredPostPublishable(Post $post): void * * @return array{0: string, 1: string}|null [field, message] */ - private static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array + public static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array { return match (true) { $platform === Platform::TikTok && blank(data_get($meta, 'privacy_level')) => ['privacy_level', trans('posts.form.tiktok.privacy_required')], diff --git a/app/Support/Repurpose/DestinationMetaRules.php b/app/Support/Repurpose/DestinationMetaRules.php new file mode 100644 index 000000000..8c2a41fc7 --- /dev/null +++ b/app/Support/Repurpose/DestinationMetaRules.php @@ -0,0 +1,105 @@ + + */ + public static function rules(): array + { + return self::reKey(PostPlatformMetaRules::rules()); + } + + /** + * @return array + */ + public static function messages(): array + { + return self::reKey(PostPlatformMetaRules::messages()); + } + + /** + * @return array + */ + public static function attributes(): array + { + return self::reKey(PostPlatformMetaRules::attributes()); + } + + 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 + { + $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 + */ + private static function reKey(array $entries): array + { + $destinations = []; + + foreach ($entries as $key => $entry) { + if (! Str::startsWith($key, 'platforms.*.meta')) { + continue; + } + + $destinations[Str::replaceFirst('platforms.*.', 'destinations.*.', $key)] = $entry; + } + + return $destinations; + } +} diff --git a/app/Support/Repurpose/RepurposeRules.php b/app/Support/Repurpose/RepurposeRules.php new file mode 100644 index 000000000..3b4312984 --- /dev/null +++ b/app/Support/Repurpose/RepurposeRules.php @@ -0,0 +1,98 @@ + + */ + public static function settings(?string $workspaceId, bool $sourceRequired): array + { + return [ + 'source_social_account_id' => [ + $sourceRequired ? 'required' : 'sometimes', + 'string', + 'uuid', + Rule::exists('social_accounts', 'id') + ->where('workspace_id', $workspaceId) + ->where('is_active', true) + ->whereIn('platform', array_map( + fn (Platform $platform): string => $platform->value, + SourceFetcherFactory::supportedPlatforms(), + )), + ], + 'source_format' => ['sometimes', Rule::enum(SourceFormat::class)], + 'publish_mode' => ['sometimes', Rule::enum(PublishMode::class)], + ]; + } + + /** + * @return array + */ + public static function destinations(?string $workspaceId): array + { + return [ + 'destinations' => ['sometimes', 'array'], + 'destinations.*.social_account_id' => [ + 'required', + 'string', + 'uuid', + Rule::exists('social_accounts', 'id') + ->where('workspace_id', $workspaceId), + ], + 'destinations.*.content_type' => [ + 'required', + 'string', + Rule::enum(ContentType::class), + new ContentTypeMatchesPlatform, + fn (string $attribute, mixed $value, callable $fail) => ContentType::tryFrom((string) $value)?->supportsVideo() === false + ? $fail(__('repurposes.errors.destination_needs_video')) + : null, + ], + ...DestinationMetaRules::rules(), + ]; + } + + /** + * @return array + */ + public static function messages(): array + { + return [ + 'destinations.*.social_account_id.exists' => __('repurposes.errors.destination_unavailable'), + 'source_social_account_id.exists' => __('repurposes.errors.source_unavailable'), + ...DestinationMetaRules::messages(), + ]; + } + + /** + * @return array + */ + public static function attributes(): array + { + return [ + 'destinations.*.social_account_id' => __('repurposes.destinations.title'), + 'destinations.*.content_type' => __('repurposes.destinations.publish_as'), + 'source_social_account_id' => __('repurposes.source.title'), + ...DestinationMetaRules::attributes(), + ]; + } +} diff --git a/app/Support/Repurpose/RepurposeTransition.php b/app/Support/Repurpose/RepurposeTransition.php new file mode 100644 index 000000000..ff87eb6e4 --- /dev/null +++ b/app/Support/Repurpose/RepurposeTransition.php @@ -0,0 +1,51 @@ + $from + * @param callable(Repurpose): void $change + */ + public static function apply(Repurpose $repurpose, array $from, string $message, callable $change): Repurpose + { + return DB::transaction(function () use ($repurpose, $from, $message, $change): Repurpose { + $locked = Repurpose::query()->whereKey($repurpose->id)->lockForUpdate()->firstOrFail(); + + if (! in_array($locked->status, $from, true)) { + throw ValidationException::withMessages(['status' => $message]); + } + + $change($locked); + + return $locked->fresh(); + }); + } + + /** + * @param array $from + * @param callable(Repurpose): void $change + */ + public static function applyIfPossible(Repurpose $repurpose, array $from, callable $change): ?Repurpose + { + return DB::transaction(function () use ($repurpose, $from, $change): ?Repurpose { + $locked = Repurpose::query()->whereKey($repurpose->id)->lockForUpdate()->first(); + + if ($locked === null || ! in_array($locked->status, $from, true)) { + return null; + } + + $change($locked); + + return $locked->fresh(); + }); + } +} 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/app/Support/Repurpose/SourceIsNotADestination.php b/app/Support/Repurpose/SourceIsNotADestination.php new file mode 100644 index 000000000..8fc9b5498 --- /dev/null +++ b/app/Support/Repurpose/SourceIsNotADestination.php @@ -0,0 +1,57 @@ + $destinations + */ + public static function addErrors(Validator $validator, array $destinations, ?string $sourceAccountId): void + { + foreach (self::offendingKeys($destinations, $sourceAccountId) as $key) { + $validator->errors()->add($key, __('repurposes.errors.destination_is_source')); + } + } + + /** + * @param array $destinations + */ + public static function assert(array $destinations, ?string $sourceAccountId): void + { + $keys = self::offendingKeys($destinations, $sourceAccountId); + + if ($keys !== []) { + throw ValidationException::withMessages(array_fill_keys( + $keys, + __('repurposes.errors.destination_is_source'), + )); + } + } + + /** + * @param array $destinations + * @return array + */ + private static function offendingKeys(array $destinations, ?string $sourceAccountId): array + { + if ($sourceAccountId === null) { + return []; + } + + $keys = []; + + foreach ($destinations as $index => $destination) { + if (data_get($destination, 'social_account_id') === $sourceAccountId) { + $keys[] = "destinations.{$index}.social_account_id"; + } + } + + return $keys; + } +} diff --git a/config/trypost.php b/config/trypost.php index 5eff1009e..90b50ee01 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -153,6 +153,26 @@ 'user_agent' => env('TRYPOST_USER_AGENT', 'TryPost.it/1.0 (+https://trypost.it)'), + /* + |-------------------------------------------------------------------------- + | Repurpose + |-------------------------------------------------------------------------- + | + | How often an active repurpose polls its source network for videos the + | workspace published outside TryPost. The scheduler ticks every five + | minutes and each repurpose is polled when it is due, so the interval is + | a runtime knob rather than a cron expression. Meta's Instagram quota is + | an app-wide pool (200 calls per hour per daily active user), so raise + | the interval before the pool tightens. `backoff_minutes` is used instead + | when the source answers with a rate-limit error. + | + */ + + 'repurpose' => [ + 'poll_interval_minutes' => (int) env('REPURPOSE_POLL_INTERVAL_MINUTES', 15), + 'backoff_minutes' => (int) env('REPURPOSE_BACKOFF_MINUTES', 60), + ], + 'google_auth_enabled' => env('GOOGLE_AUTH_ENABLED', false), 'github_auth_enabled' => env('GITHUB_AUTH_ENABLED', false), diff --git a/database/factories/RepurposeFactory.php b/database/factories/RepurposeFactory.php new file mode 100644 index 000000000..c19d8127a --- /dev/null +++ b/database/factories/RepurposeFactory.php @@ -0,0 +1,61 @@ + + */ +class RepurposeFactory extends Factory +{ + protected $model = Repurpose::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'user_id' => User::factory(), + 'source_social_account_id' => SocialAccount::factory(), + 'source_format' => SourceFormat::Reel, + 'publish_mode' => PublishMode::Publish, + 'destinations' => [], + 'status' => Status::Draft, + ]; + } + + public function active(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => Status::Active, + 'activated_at' => now(), + ]); + } + + public function paused(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => Status::Paused, + 'activated_at' => now()->subDay(), + ]); + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => Status::Disabled, + ]); + } +} diff --git a/database/factories/RepurposeItemFactory.php b/database/factories/RepurposeItemFactory.php new file mode 100644 index 000000000..943ea672f --- /dev/null +++ b/database/factories/RepurposeItemFactory.php @@ -0,0 +1,32 @@ + + */ +class RepurposeItemFactory extends Factory +{ + protected $model = RepurposeItem::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'repurpose_id' => Repurpose::factory(), + 'source_media_id' => fake()->uuid(), + 'source_permalink' => fake()->url(), + 'source_created_at' => now()->subMinutes(10), + 'status' => ItemStatus::Pending, + ]; + } +} diff --git a/database/migrations/2026_09_05_183105_create_repurposes_table.php b/database/migrations/2026_09_05_183105_create_repurposes_table.php new file mode 100644 index 000000000..ba844b176 --- /dev/null +++ b/database/migrations/2026_09_05_183105_create_repurposes_table.php @@ -0,0 +1,46 @@ +uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignUuid('user_id')->nullable()->constrained('users')->nullOnDelete(); + // Nullable and nullOnDelete: deleting the watched account must not + // take the repurpose and its whole activity history with it. The + // observer pauses it instead and the page asks for a new source. + $table->foreignUuid('source_social_account_id')->nullable()->constrained('social_accounts')->nullOnDelete(); + $table->string('source_format')->default(SourceFormat::Reel->value); + $table->string('publish_mode')->default(PublishMode::Publish->value); + $table->json('destinations'); + $table->string('status'); + $table->string('paused_reason')->nullable(); + $table->timestamp('activated_at')->nullable(); + $table->timestamp('last_polled_at')->nullable(); + $table->timestamp('next_poll_at')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + + $table->unique( + ['workspace_id', 'source_social_account_id', 'source_format'], + 'repurposes_source_format_unique', + ); + $table->index(['status', 'next_poll_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('repurposes'); + } +}; diff --git a/database/migrations/2026_09_05_183106_create_repurpose_items_table.php b/database/migrations/2026_09_05_183106_create_repurpose_items_table.php new file mode 100644 index 000000000..5c0617de3 --- /dev/null +++ b/database/migrations/2026_09_05_183106_create_repurpose_items_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->foreignUuid('repurpose_id')->constrained('repurposes')->cascadeOnDelete(); + $table->string('source_media_id'); + $table->text('source_permalink')->nullable(); + $table->timestamp('source_created_at')->nullable(); + $table->string('status'); + $table->string('reason')->nullable(); + $table->text('error')->nullable(); + $table->timestamps(); + + $table->unique(['repurpose_id', 'source_media_id']); + $table->index(['repurpose_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('repurpose_items'); + } +}; diff --git a/database/migrations/2026_09_05_183108_add_repurpose_item_id_to_posts_table.php b/database/migrations/2026_09_05_183108_add_repurpose_item_id_to_posts_table.php new file mode 100644 index 000000000..1759f9acb --- /dev/null +++ b/database/migrations/2026_09_05_183108_add_repurpose_item_id_to_posts_table.php @@ -0,0 +1,24 @@ +foreignUuid('repurpose_item_id')->nullable()->after('created_via')->constrained('repurpose_items')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('posts', function (Blueprint $table) { + $table->dropConstrainedForeignId('repurpose_item_id'); + }); + } +}; diff --git a/database/migrations/2026_09_05_222232_add_platform_post_id_index_to_post_platforms_table.php b/database/migrations/2026_09_05_222232_add_platform_post_id_index_to_post_platforms_table.php new file mode 100644 index 000000000..7b347c382 --- /dev/null +++ b/database/migrations/2026_09_05_222232_add_platform_post_id_index_to_post_platforms_table.php @@ -0,0 +1,24 @@ +index('platform_post_id'); + }); + } + + public function down(): void + { + Schema::table('post_platforms', function (Blueprint $table): void { + $table->dropIndex(['platform_post_id']); + }); + } +}; diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 500991a2d..06b29447d 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -123,6 +123,9 @@ ], 'flash' => [ + 'activated_resumed_repurposes' => 'تم تفعيل الحساب. تم استئناف :count أتمتة.|تم تفعيل الحساب. تم استئناف :count أتمتة.', + 'disconnected_paused_repurposes' => 'تم فصل الحساب. تم إيقاف :count أتمتة مؤقتًا.|تم فصل الحساب. تم إيقاف :count أتمتة مؤقتًا.', + 'deactivated_paused_repurposes' => 'تم إيقاف الحساب. تم إيقاف :count أتمتة مؤقتًا.|تم إيقاف الحساب. تم إيقاف :count أتمتة مؤقتًا.', 'disconnected' => 'تم فصل الحساب بنجاح!', 'connected' => 'تم ربط الحساب بنجاح!', 'session_expired' => 'انتهت الجلسة. يرجى المحاولة مرة أخرى.', 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 new file mode 100644 index 000000000..96c92dca0 --- /dev/null +++ b/lang/ar/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'أعد نشر ما تنشره خارج TryPost على شبكاتك الأخرى تلقائيًا.', + 'new' => 'repurpose جديد', + + 'flow' => [ + 'no_source' => 'لا يوجد حساب مصدر', + 'no_destinations' => 'لا توجد وجهة بعد', + ], + + 'publish_mode' => [ + + 'title' => 'النشر', + + 'description' => 'ما الذي يحدث عند ظهور منشور جديد.', + + ], + + 'publish_modes' => [ + + 'publish' => 'النشر تلقائيًا', + + 'publish_hint' => 'تتم جدولة كل منشور جديد فور العثور عليه.', + + 'draft' => 'الإنشاء كمسودة', + + 'draft_hint' => 'يصبح كل منشور جديد مسودة هنا لمراجعتها ونشرها.', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => 'مقاطع الفيديو', + 'story' => 'Stories', + ], + + 'source' => [ + 'title' => 'المصدر', + 'description' => 'يراقب TryPost هذا الحساب بحثًا عن منشورات جديدة بالصيغة أدناه.', + 'account_label' => 'الحساب', + 'watch_label' => 'المراقبة', + 'needs_reconnect' => 'يحتاج إلى إعادة اتصال', + ], + + 'summary' => [ + 'sentence' => 'كل :format جديد تنشره على :source يُعاد نشره على :destinations.', + 'no_destinations' => 'كل :format جديد تنشره على :source ما زال بانتظار وجهة.', + 'no_source' => 'لا يوجد حساب مصدر لهذه الأتمتة. اختر حسابًا لتشغيلها من جديد.', + ], + + 'empty' => [ + 'title' => 'لم يتم إعداد أي repurpose بعد', + 'description' => 'يراقب TryPost الحساب الذي تختاره ويعيد نشر كل منشور جديد على الشبكات التي تحددها.', + ], + + 'table' => [ + 'flow' => 'التدفق', + 'status' => 'الحالة', + 'published' => 'تم النسخ', + 'last_polled' => 'آخر فحص', + ], + + 'status' => [ + 'draft' => 'مسودة', + 'active' => 'نشط', + 'paused' => 'متوقف مؤقتًا', + 'disabled' => 'معطّل', + ], + + 'create' => [ + 'title' => 'repurpose جديد', + 'description' => 'اختر الحساب الذي يجب أن يراقبه TryPost. تختار الوجهات في الشاشة التالية.', + 'source_label' => 'حساب المصدر', + 'source_placeholder' => 'اختر حسابًا', + 'source_search' => 'البحث عن الحسابات', + 'source_empty' => 'لم يتم العثور على حساب.', + 'source_placeholder' => 'اختر حسابًا', + 'no_accounts' => 'اربط أولًا حساب Instagram أو Facebook. هذان فقط يصلحان كمصدر، لأنهما الشبكتان الوحيدتان اللتان تسمحان بتنزيل الفيديو.', + 'submit' => 'إنشاء', + 'connect' => 'ربط حساب', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => 'جارٍ الحفظ...', + 'saved' => 'تم الحفظ', + ], + + 'tabs' => [ + 'configuration' => 'الإعداد', + 'activity' => 'النشاط', + 'settings' => 'الإعدادات', + ], + + 'destinations' => [ + 'paused_note' => 'موقوفة ويتم تخطيها حتى تعيد تفعيلها: :accounts', + 'title' => 'الوجهات', + 'description' => 'اختر الحسابات التي ستستقبله. ينشر كل حساب بالصيغة التي تحددها.', + 'hint' => 'يُعدَّل النص لكل شبكة فقط عندما يتجاوز حد تلك الشبكة.', + 'none_available' => 'لا يوجد حساب آخر متصل في مساحة العمل هذه بعد.', + 'publish_as' => 'النشر كـ', + ], + + 'status_card' => [ + 'title' => 'الحالة', + 'activate' => 'تفعيل', + 'pause' => 'إيقاف مؤقت', + 'resume' => 'استئناف', + 'disable' => 'تعطيل', + 'watermark' => 'المراقبة منذ', + 'last_polled' => 'آخر فحص', + 'draft_hint' => 'اختر وجهة واحدة على الأقل ثم فعّل. تُنسخ فقط المنشورات المنشورة بعد التفعيل.', + 'active_hint' => 'يفحص TryPost هذا الحساب بانتظام وينسخ كل منشور جديد.', + 'paused_hint' => 'الفحوصات متوقفة. الاستئناف يكمل من حيث توقف ولا يضيع شيء نُشر في الأثناء.', + 'disabled_hint' => 'معطّل. التفعيل من جديد يبدأ من الصفر: ما نشرته أثناء التعطيل يبقى خارجًا.', + ], + + 'items' => [ + 'source' => 'الأصل', + 'published_at' => 'نُشر', + 'status' => 'الحالة', + 'detail' => 'التفاصيل', + 'posts' => 'نُسخ إلى', + 'view_original' => 'عرض الأصل', + 'original_from' => 'الأصل بتاريخ :date', + 'empty' => [ + 'title' => 'لا شيء بعد', + 'description' => 'ستظهر هنا المنشورات التي ينشرها هذا الحساب خارج TryPost.', + ], + 'open_post' => 'فتح المنشور', + 'statuses' => [ + 'pending' => 'في الانتظار', + 'processing' => 'قيد المعالجة', + 'published' => 'تم النسخ', + 'drafted' => 'مسودة', + 'skipped' => 'تم التخطي', + 'failed' => 'فشل', + ], + 'reasons' => [ + 'published_via_trypost' => 'تم نشره بالفعل عبر TryPost', + 'media_url_missing' => 'لم توفّر الشبكة ملفًا قابلًا للتنزيل، عادةً بسبب صوت محمي بحقوق النشر', + 'download_failed' => 'تعذّر تنزيل الفيديو', + 'post_creation_failed' => 'تعذّر إنشاء المنشورات', + 'no_usable_destinations' => 'لم تتوفر أي وجهة للنشر', + ], + ], + + 'menu' => [ + + 'label' => 'إجراءات أخرى', + + ], + + 'danger' => [ + 'title' => 'حذف هذا الـ repurpose', + 'description' => 'تتوقف الفحوصات فورًا. تبقى المنشورات التي أُنشئت في تقويمك.', + 'delete' => 'حذف الـ repurpose', + ], + + 'health' => [ + 'stopped_itself' => 'توقّفت من تلقاء نفسها — افتحها لمعرفة السبب', + 'source_missing' => 'النسخ متوقّف: لا توجد حساب مصدر لهذه الأتمتة. اختر حسابًا ثم استأنفها.', + 'source_unusable' => 'النسخ متوقّف: الحساب الذي تراقبه هذه الأتمتة يحتاج إلى إعادة ربط.', + 'no_destinations' => 'النسخ متوقّف: لا توجد وجهة متاحة. أضف وجهة ثم استأنفها.', + 'ready' => 'تم حل المشكلة. استأنف هذه الأتمتة لتعود إلى النسخ.', + ], + + 'errors' => [ + 'source_already_used' => 'هذا الحساب يغذّي بالفعل repurpose آخر. عدّل ذلك بدلًا منه.', + 'source_missing' => 'اختر حسابًا للمراقبة قبل بدء هذه الأتمتة.', + 'source_unusable' => 'أعد ربط الحساب الذي تراقبه هذه الأتمتة قبل بدئها.', + 'destinations_required' => 'اختر وجهة واحدة على الأقل قبل التفعيل.', + 'destination_needs_video' => 'هذه الصيغة لا تقبل الفيديو.', + 'only_paused_resumes' => 'لا يمكن استئناف سوى repurpose متوقف مؤقتًا.', + 'only_active_pauses' => 'لا يمكن إيقاف سوى إعادة توظيف نشطة مؤقتًا.', + 'only_running_disables' => 'لا يمكن تعطيل سوى إعادة توظيف قيد التشغيل.', + 'only_idle_activates' => 'لا يمكن تفعيل سوى مسودة أو إعادة توظيف معطّلة.', + 'destination_unavailable' => 'لم يعد حساب الوجهة هذا متاحًا.', + 'destination_is_source' => 'هذه الوجهة هي الحساب نفسه الذي تراقبه إعادة التوظيف هذه.', + 'source_unavailable' => 'لم يعد حساب المصدر هذا متاحًا.', + 'action_failed' => 'حدث خطأ ما. راجع النموذج وحاول مرة أخرى.', + ], +]; diff --git a/lang/ar/sidebar.php b/lang/ar/sidebar.php index 75f34366b..a424c1c10 100644 --- a/lang/ar/sidebar.php +++ b/lang/ar/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'أخرى', ], 'analytics' => 'التحليلات', + 'repurposes' => 'Repurpose', 'onboarding' => 'البدء', 'onboarding_hint' => 'أكمل الإعداد', 'posts' => [ diff --git a/lang/de/accounts.php b/lang/de/accounts.php index f9574718d..0fa250d39 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -125,6 +125,9 @@ ], '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!', 'connected' => 'Konto erfolgreich verbunden!', 'session_expired' => 'Sitzung abgelaufen. Bitte versuche es erneut.', 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 new file mode 100644 index 000000000..41cbe8ded --- /dev/null +++ b/lang/de/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'Was du außerhalb von TryPost postest, automatisch auf deinen anderen Netzwerken wiederveröffentlichen.', + 'new' => 'Neues Repurpose', + + 'flow' => [ + 'no_source' => 'Kein Quellkonto', + 'no_destinations' => 'Noch kein Ziel', + ], + + 'publish_mode' => [ + + 'title' => 'Veröffentlichung', + + 'description' => 'Was passiert, wenn ein neuer Beitrag auftaucht.', + + ], + + 'publish_modes' => [ + + 'publish' => 'Automatisch veröffentlichen', + + 'publish_hint' => 'Jeder neue Beitrag wird eingeplant, sobald er gefunden wird.', + + 'draft' => 'Als Entwurf anlegen', + + 'draft_hint' => 'Jeder neue Beitrag wird hier zum Entwurf, den du prüfen und veröffentlichen kannst.', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => 'Videos', + 'story' => 'Stories', + ], + + 'source' => [ + 'title' => 'Quelle', + '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', + ], + + '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' => [ + 'title' => 'Noch kein Repurpose eingerichtet', + 'description' => 'TryPost beobachtet das gewählte Konto und veröffentlicht jeden neuen Beitrag auf den ausgewählten Netzwerken erneut.', + ], + + 'table' => [ + 'flow' => 'Ablauf', + 'status' => 'Status', + 'published' => 'Repliziert', + 'last_polled' => 'Zuletzt geprüft', + ], + + 'status' => [ + 'draft' => 'Entwurf', + 'active' => 'Aktiv', + 'paused' => 'Pausiert', + 'disabled' => 'Deaktiviert', + ], + + 'create' => [ + 'title' => 'Neues Repurpose', + 'description' => 'Wähle das Konto, das TryPost beobachten soll. Die Ziele wählst du im nächsten Schritt.', + 'source_label' => 'Quellkonto', + 'source_placeholder' => 'Konto auswählen', + 'source_search' => 'Konten suchen', + 'source_empty' => 'Kein Konto gefunden.', + 'source_placeholder' => 'Konto auswählen', + 'no_accounts' => 'Verbinde zuerst ein Instagram- oder Facebook-Konto. Nur diese können Quelle sein, weil nur sie den Download des Videos erlauben.', + 'submit' => 'Erstellen', + 'connect' => 'Konto verbinden', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => 'Wird gespeichert...', + 'saved' => 'Gespeichert', + ], + + 'tabs' => [ + 'configuration' => 'Konfiguration', + 'activity' => 'Aktivität', + 'settings' => 'Einstellungen', + ], + + 'destinations' => [ + 'paused_note' => 'Deaktiviert und übersprungen, bis du sie wieder einschaltest: :accounts', + 'title' => 'Ziele', + '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.', + 'publish_as' => 'Veröffentlichen als', + ], + + 'status_card' => [ + 'title' => 'Status', + 'activate' => 'Aktivieren', + 'pause' => 'Pausieren', + 'resume' => 'Fortsetzen', + 'disable' => 'Deaktivieren', + 'watermark' => 'Beobachtet seit', + 'last_polled' => 'Zuletzt geprüft', + '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.', + ], + + 'items' => [ + 'source' => 'Original', + 'published_at' => 'Gepostet', + 'status' => 'Status', + 'detail' => 'Detail', + 'posts' => 'Repliziert auf', + 'view_original' => 'Original ansehen', + 'original_from' => 'Original vom :date', + 'empty' => [ + 'title' => 'Noch nichts', + 'description' => 'Beiträge, die dieses Konto außerhalb von TryPost veröffentlicht, erscheinen hier.', + ], + 'open_post' => 'Beitrag öffnen', + 'statuses' => [ + 'pending' => 'In Warteschlange', + 'processing' => 'Wird verarbeitet', + 'published' => 'Repliziert', + 'drafted' => 'Entwurf', + 'skipped' => 'Übersprungen', + 'failed' => 'Fehlgeschlagen', + ], + 'reasons' => [ + 'published_via_trypost' => 'Bereits über TryPost veröffentlicht', + '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' => 'Kein Ziel war zum Veröffentlichen verfügbar', + ], + ], + + 'menu' => [ + + 'label' => 'Weitere Aktionen', + + ], + + 'danger' => [ + 'title' => 'Dieses Repurpose löschen', + 'description' => 'Die Prüfungen stoppen sofort. Bereits erstellte Beiträge bleiben in deinem Kalender.', + '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.', + 'source_unusable' => 'Verbinde das überwachte Konto erneut, bevor du diese Automatisierung startest.', + 'destinations_required' => 'Wähle vor dem Aktivieren mindestens ein Ziel.', + 'destination_needs_video' => 'Dieses Format kann kein Video tragen.', + 'only_paused_resumes' => 'Nur ein pausiertes Repurpose kann fortgesetzt werden.', + 'only_active_pauses' => 'Nur ein aktives Repurpose kann pausiert werden.', + 'only_running_disables' => 'Nur ein laufendes Repurpose kann deaktiviert werden.', + 'only_idle_activates' => 'Nur ein Entwurf oder ein deaktiviertes Repurpose kann aktiviert werden.', + 'destination_unavailable' => 'Dieses Zielkonto ist nicht mehr verfügbar.', + 'destination_is_source' => 'Dieses Ziel ist das Konto, das dieses Repurpose beobachtet.', + 'source_unavailable' => 'Dieses Quellkonto ist nicht mehr verfügbar.', + 'action_failed' => 'Etwas ist schiefgelaufen. Prüfe das Formular und versuche es erneut.', + ], +]; diff --git a/lang/de/sidebar.php b/lang/de/sidebar.php index 73160a271..0807a91a6 100644 --- a/lang/de/sidebar.php +++ b/lang/de/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Sonstiges', ], 'analytics' => 'Analytics', + 'repurposes' => 'Repurpose', 'onboarding' => 'Erste Schritte', 'onboarding_hint' => 'Einrichtung abschließen', 'posts' => [ diff --git a/lang/el/accounts.php b/lang/el/accounts.php index 2b2fffaf7..4d225a482 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -123,6 +123,9 @@ ], 'flash' => [ + 'activated_resumed_repurposes' => 'Ο λογαριασμός ενεργοποιήθηκε. :count αυτοματοποίηση συνεχίστηκε.|Ο λογαριασμός ενεργοποιήθηκε. :count αυτοματοποιήσεις συνεχίστηκαν.', + 'disconnected_paused_repurposes' => 'Ο λογαριασμός αποσυνδέθηκε. :count αυτοματοποίηση σε παύση.|Ο λογαριασμός αποσυνδέθηκε. :count αυτοματοποιήσεις σε παύση.', + 'deactivated_paused_repurposes' => 'Ο λογαριασμός απενεργοποιήθηκε. :count αυτοματοποίηση σε παύση.|Ο λογαριασμός απενεργοποιήθηκε. :count αυτοματοποιήσεις σε παύση.', 'disconnected' => 'Ο λογαριασμός αποσυνδέθηκε με επιτυχία!', 'connected' => 'Ο λογαριασμός συνδέθηκε με επιτυχία!', 'session_expired' => 'Η συνεδρία έληξε. Παρακαλούμε δοκιμάστε ξανά.', 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 new file mode 100644 index 000000000..e245ca59e --- /dev/null +++ b/lang/el/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'Αναδημοσίευσε αυτόματα στα άλλα σου δίκτυα ό,τι ανεβάζεις εκτός TryPost.', + 'new' => 'Νέο repurpose', + + 'flow' => [ + 'no_source' => 'Χωρίς λογαριασμό προέλευσης', + 'no_destinations' => 'Κανένας προορισμός ακόμη', + ], + + 'publish_mode' => [ + + 'title' => 'Δημοσίευση', + + 'description' => 'Τι συμβαίνει όταν εμφανίζεται νέα ανάρτηση.', + + ], + + 'publish_modes' => [ + + 'publish' => 'Αυτόματη δημοσίευση', + + 'publish_hint' => 'Κάθε νέα ανάρτηση προγραμματίζεται μόλις βρεθεί.', + + 'draft' => 'Δημιουργία ως πρόχειρο', + + 'draft_hint' => 'Κάθε νέα ανάρτηση γίνεται πρόχειρο εδώ για έλεγχο και δημοσίευση.', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => 'Βίντεο', + 'story' => 'Stories', + ], + + 'source' => [ + 'title' => 'Πηγή', + 'description' => 'Το TryPost παρακολουθεί αυτόν τον λογαριασμό για νέες αναρτήσεις της παρακάτω μορφής.', + 'account_label' => 'Λογαριασμός', + 'watch_label' => 'Παρακολούθηση', + 'needs_reconnect' => 'Χρειάζεται επανασύνδεση', + ], + + 'summary' => [ + 'sentence' => 'Κάθε νέο :format που ανεβάζεις στο :source αναδημοσιεύεται σε :destinations.', + 'no_destinations' => 'Κάθε νέο :format στο :source περιμένει ακόμη προορισμό.', + 'no_source' => 'Αυτή η αυτοματοποίηση δεν έχει λογαριασμό προέλευσης. Επίλεξε έναν για να την ξεκινήσεις ξανά.', + ], + + 'empty' => [ + 'title' => 'Δεν έχει ρυθμιστεί repurpose ακόμη', + 'description' => 'Το TryPost παρακολουθεί τον λογαριασμό που επιλέγεις και αναδημοσιεύει κάθε νέα ανάρτηση στα δίκτυα που σημειώνεις.', + ], + + 'table' => [ + 'flow' => 'Ροή', + 'status' => 'Κατάσταση', + 'published' => 'Αναπαράχθηκαν', + 'last_polled' => 'Τελευταίος έλεγχος', + ], + + 'status' => [ + 'draft' => 'Πρόχειρο', + 'active' => 'Ενεργό', + 'paused' => 'Σε παύση', + 'disabled' => 'Απενεργοποιημένο', + ], + + 'create' => [ + 'title' => 'Νέο repurpose', + 'description' => 'Διάλεξε τον λογαριασμό που θα παρακολουθεί το TryPost. Τους προορισμούς τους επιλέγεις στην επόμενη οθόνη.', + 'source_label' => 'Λογαριασμός πηγής', + 'source_placeholder' => 'Επιλέξτε λογαριασμό', + 'source_search' => 'Αναζήτηση λογαριασμών', + 'source_empty' => 'Δεν βρέθηκε λογαριασμός.', + 'source_placeholder' => 'Επίλεξε λογαριασμό', + 'no_accounts' => 'Σύνδεσε πρώτα έναν λογαριασμό Instagram ή Facebook. Μόνο αυτοί μπορούν να είναι πηγή, γιατί μόνο αυτά τα δίκτυα επιτρέπουν τη λήψη του βίντεο.', + 'submit' => 'Δημιουργία', + 'connect' => 'Σύνδεση λογαριασμού', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => 'Αποθήκευση...', + 'saved' => 'Αποθηκεύτηκε', + ], + + 'tabs' => [ + 'configuration' => 'Ρύθμιση', + 'activity' => 'Δραστηριότητα', + 'settings' => 'Ρυθμίσεις', + ], + + 'destinations' => [ + 'paused_note' => 'Απενεργοποιημένοι και παραλείπονται μέχρι να τους ενεργοποιήσεις ξανά: :accounts', + 'title' => 'Προορισμοί', + 'description' => 'Διάλεξε τους λογαριασμούς που θα το λάβουν. Καθένας δημοσιεύει στη μορφή που ορίζεις.', + 'hint' => 'Η λεζάντα προσαρμόζεται ανά δίκτυο μόνο όταν ξεπερνά το όριο εκείνου του δικτύου.', + 'none_available' => 'Δεν υπάρχει άλλος συνδεδεμένος λογαριασμός σε αυτόν τον χώρο εργασίας.', + 'publish_as' => 'Δημοσίευση ως', + ], + + 'status_card' => [ + 'title' => 'Κατάσταση', + 'activate' => 'Ενεργοποίηση', + 'pause' => 'Παύση', + 'resume' => 'Συνέχιση', + 'disable' => 'Απενεργοποίηση', + 'watermark' => 'Παρακολούθηση από', + 'last_polled' => 'Τελευταίος έλεγχος', + 'draft_hint' => 'Διάλεξε τουλάχιστον έναν προορισμό και ενεργοποίησε. Αναπαράγονται μόνο αναρτήσεις μετά την ενεργοποίηση.', + 'active_hint' => 'Το TryPost ελέγχει τακτικά αυτόν τον λογαριασμό και αναπαράγει κάθε νέα ανάρτηση.', + 'paused_hint' => 'Οι έλεγχοι είναι σε αναμονή. Η συνέχιση ξεκινά από εκεί που σταμάτησε και δεν χάνεται τίποτα.', + 'disabled_hint' => 'Απενεργοποιημένο. Η εκ νέου ενεργοποίηση ξεκινά από την αρχή: ό,τι ανέβασες όσο ήταν κλειστό μένει εκτός.', + ], + + 'items' => [ + 'source' => 'Πρωτότυπο', + 'published_at' => 'Δημοσιεύτηκε', + 'status' => 'Κατάσταση', + 'detail' => 'Λεπτομέρεια', + 'posts' => 'Αναπαράχθηκε σε', + 'view_original' => 'Δες το πρωτότυπο', + 'original_from' => 'πρωτότυπο από :date', + 'empty' => [ + 'title' => 'Τίποτα ακόμη', + 'description' => 'Οι αναρτήσεις που κάνει αυτός ο λογαριασμός εκτός TryPost θα εμφανίζονται εδώ.', + ], + 'open_post' => 'Άνοιγμα ανάρτησης', + 'statuses' => [ + 'pending' => 'Σε αναμονή', + 'processing' => 'Σε επεξεργασία', + 'published' => 'Αναπαράχθηκε', + 'drafted' => 'Πρόχειρο', + 'skipped' => 'Παραλείφθηκε', + 'failed' => 'Απέτυχε', + ], + 'reasons' => [ + 'published_via_trypost' => 'Δημοσιεύτηκε ήδη μέσω TryPost', + 'media_url_missing' => 'Το δίκτυο δεν έδωσε αρχείο για λήψη, συνήθως λόγω ήχου με πνευματικά δικαιώματα', + 'download_failed' => 'Δεν ήταν δυνατή η λήψη του βίντεο', + 'post_creation_failed' => 'Δεν ήταν δυνατή η δημιουργία των αναρτήσεων', + 'no_usable_destinations' => 'Δεν υπήρχε διαθέσιμος προορισμός για δημοσίευση', + ], + ], + + 'menu' => [ + + 'label' => 'Περισσότερες ενέργειες', + + ], + + 'danger' => [ + 'title' => 'Διαγραφή αυτού του repurpose', + 'description' => 'Οι έλεγχοι σταματούν αμέσως. Οι αναρτήσεις που δημιουργήθηκαν παραμένουν στο ημερολόγιό σου.', + 'delete' => 'Διαγραφή repurpose', + ], + + 'health' => [ + 'stopped_itself' => 'Σταμάτησε μόνη της — άνοιξέ την για να δεις γιατί', + 'source_missing' => 'Η αναπαραγωγή είναι σε παύση: αυτή η αυτοματοποίηση δεν έχει λογαριασμό προέλευσης. Επίλεξε έναν και συνέχισε.', + 'source_unusable' => 'Η αναπαραγωγή είναι σε παύση: ο λογαριασμός που παρακολουθείται χρειάζεται επανασύνδεση.', + 'no_destinations' => 'Η αναπαραγωγή είναι σε παύση: δεν υπάρχει διαθέσιμος προορισμός. Πρόσθεσε έναν και συνέχισε.', + 'ready' => 'Το πρόβλημα λύθηκε. Συνέχισε αυτήν την αυτοματοποίηση για να ξαναρχίσει η αναπαραγωγή.', + ], + + 'errors' => [ + 'source_already_used' => 'Αυτός ο λογαριασμός τροφοδοτεί ήδη άλλο repurpose. Επεξεργάσου εκείνο.', + 'source_missing' => 'Επίλεξε έναν λογαριασμό για παρακολούθηση πριν ξεκινήσεις αυτήν την αυτοματοποίηση.', + 'source_unusable' => 'Επανασύνδεσε τον λογαριασμό που παρακολουθείται πριν ξεκινήσεις αυτήν την αυτοματοποίηση.', + 'destinations_required' => 'Διάλεξε τουλάχιστον έναν προορισμό πριν την ενεργοποίηση.', + 'destination_needs_video' => 'Αυτή η μορφή δεν δέχεται βίντεο.', + 'only_paused_resumes' => 'Μόνο ένα repurpose σε παύση μπορεί να συνεχιστεί.', + 'only_active_pauses' => 'Μόνο ένα ενεργό repurpose μπορεί να τεθεί σε παύση.', + 'only_running_disables' => 'Μόνο ένα repurpose σε λειτουργία μπορεί να απενεργοποιηθεί.', + 'only_idle_activates' => 'Μόνο ένα πρόχειρο ή απενεργοποιημένο repurpose μπορεί να ενεργοποιηθεί.', + 'destination_unavailable' => 'Αυτός ο λογαριασμός προορισμού δεν είναι πλέον διαθέσιμος.', + 'destination_is_source' => 'Αυτός ο προορισμός είναι ο λογαριασμός που παρακολουθεί αυτό το repurpose.', + 'source_unavailable' => 'Αυτός ο λογαριασμός προέλευσης δεν είναι πλέον διαθέσιμος.', + 'action_failed' => 'Κάτι πήγε στραβά. Έλεγξε τη φόρμα και δοκίμασε ξανά.', + ], +]; diff --git a/lang/el/sidebar.php b/lang/el/sidebar.php index dcb51f2f0..9e3db2be6 100644 --- a/lang/el/sidebar.php +++ b/lang/el/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Άλλα', ], 'analytics' => 'Στατιστικά', + 'repurposes' => 'Repurpose', 'onboarding' => 'Ξεκινώντας', 'onboarding_hint' => 'Ολοκλήρωση ρύθμισης', 'posts' => [ diff --git a/lang/en/accounts.php b/lang/en/accounts.php index ce62dbfdb..e181f2010 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -123,6 +123,9 @@ ], '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!', 'connected' => 'Account connected successfully!', 'session_expired' => 'Session expired. Please try again.', 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 new file mode 100644 index 000000000..984e0e82d --- /dev/null +++ b/lang/en/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'Replicate what you post outside TryPost to your other networks, automatically.', + 'new' => 'New repurpose', + + 'flow' => [ + 'no_source' => 'No source account', + 'no_destinations' => 'No destination yet', + ], + + 'publish_mode' => [ + + 'title' => 'Publishing', + + 'description' => 'What happens when a new post shows up.', + + ], + + 'publish_modes' => [ + + 'publish' => 'Publish automatically', + + 'publish_hint' => 'Each new post is scheduled the moment it is found.', + + 'draft' => 'Create as draft', + + 'draft_hint' => 'Each new post becomes a draft here for you to review and publish.', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => 'Videos', + 'story' => 'Stories', + ], + + 'source' => [ + 'title' => 'Source', + 'description' => 'TryPost watches this account for new posts of the format below.', + 'account_label' => 'Account', + 'watch_label' => 'Watch for', + 'needs_reconnect' => 'Needs reconnecting', + ], + + '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' => [ + 'title' => 'No repurpose set up yet', + 'description' => 'TryPost watches the account you choose and republishes every new post to the networks you pick.', + ], + + 'table' => [ + 'flow' => 'Flow', + 'status' => 'Status', + 'published' => 'Replicated', + 'last_polled' => 'Last checked', + ], + + 'status' => [ + 'draft' => 'Draft', + 'active' => 'Active', + 'paused' => 'Paused', + 'disabled' => 'Disabled', + ], + + 'create' => [ + 'title' => 'New repurpose', + 'description' => 'Choose the account TryPost should watch. You pick the destinations on the next screen.', + 'source_label' => 'Source account', + 'source_placeholder' => 'Choose an account', + 'source_search' => 'Search accounts', + 'source_empty' => 'No account found.', + 'source_placeholder' => 'Select an account', + 'no_accounts' => 'Connect an Instagram or Facebook account first. Only these can be a source, because they are the only networks that let us download the video.', + 'submit' => 'Create', + 'connect' => 'Connect an account', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => 'Saving...', + 'saved' => 'Saved', + ], + + 'tabs' => [ + 'configuration' => 'Configuration', + 'activity' => 'Activity', + 'settings' => 'Settings', + ], + + 'destinations' => [ + 'paused_note' => 'Switched off and skipped until you turn them back on: :accounts', + 'title' => 'Destinations', + '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.', + 'publish_as' => 'Publish as', + ], + + 'status_card' => [ + 'title' => 'Status', + 'activate' => 'Activate', + 'pause' => 'Pause', + 'resume' => 'Resume', + 'disable' => 'Disable', + 'watermark' => 'Watching since', + 'last_polled' => 'Last checked', + '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.', + ], + + 'items' => [ + 'source' => 'Original', + 'published_at' => 'Posted', + 'status' => 'Status', + 'detail' => 'Detail', + 'posts' => 'Replicated to', + 'view_original' => 'View original', + 'original_from' => 'original from :date', + 'empty' => [ + 'title' => 'Nothing yet', + 'description' => 'Posts this account publishes outside TryPost will show up here.', + ], + 'open_post' => 'Open post', + 'statuses' => [ + 'pending' => 'Queued', + 'processing' => 'Processing', + 'published' => 'Replicated', + 'drafted' => 'Drafted', + 'skipped' => 'Skipped', + 'failed' => 'Failed', + ], + 'reasons' => [ + 'published_via_trypost' => 'Already published through TryPost', + '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' => 'No destination was available to publish to', + ], + ], + + 'menu' => [ + + 'label' => 'More actions', + + ], + + 'danger' => [ + 'title' => 'Delete this repurpose', + 'description' => 'Checks stop immediately. Posts already created stay in your calendar.', + '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.', + 'source_unusable' => 'Reconnect the account this repurpose watches before starting it.', + 'destinations_required' => 'Pick at least one destination before activating.', + 'destination_needs_video' => 'That format cannot carry a video.', + 'only_paused_resumes' => 'Only a paused repurpose can be resumed.', + 'only_active_pauses' => 'Only an active repurpose can be paused.', + 'only_running_disables' => 'Only a running repurpose can be turned off.', + 'only_idle_activates' => 'Only a draft or turned-off repurpose can be activated.', + 'destination_unavailable' => 'That destination account is no longer available.', + 'destination_is_source' => 'That destination is the account this repurpose watches.', + 'source_unavailable' => 'That source account is no longer available.', + 'action_failed' => 'Something went wrong. Check the form and try again.', + ], +]; diff --git a/lang/en/sidebar.php b/lang/en/sidebar.php index 0365d9981..4a37c1eda 100644 --- a/lang/en/sidebar.php +++ b/lang/en/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Others', ], 'analytics' => 'Analytics', + 'repurposes' => 'Repurpose', 'onboarding' => 'Getting started', 'onboarding_hint' => 'Finish setup', 'posts' => [ diff --git a/lang/es/accounts.php b/lang/es/accounts.php index f1f273393..a1069f66d 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -123,6 +123,9 @@ ], '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!', 'connected' => '¡Cuenta conectada correctamente!', 'session_expired' => 'Sesión expirada. Inténtalo de nuevo.', 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 new file mode 100644 index 000000000..160155a0d --- /dev/null +++ b/lang/es/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'Replica automáticamente en tus otras redes lo que publicas fuera de TryPost.', + 'new' => 'Nuevo repurpose', + + 'flow' => [ + 'no_source' => 'Sin cuenta de origen', + 'no_destinations' => 'Aún sin destino', + ], + + 'publish_mode' => [ + + 'title' => 'Publicación', + + 'description' => 'Qué ocurre cuando aparece una publicación nueva.', + + ], + + 'publish_modes' => [ + + 'publish' => 'Publicar automáticamente', + + 'publish_hint' => 'Cada publicación nueva se programa en cuanto se encuentra.', + + 'draft' => 'Crear como borrador', + + 'draft_hint' => 'Cada publicación nueva se convierte en un borrador para que lo revises y publiques.', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => 'Vídeos', + 'story' => 'Stories', + ], + + 'source' => [ + 'title' => 'Origen', + 'description' => 'TryPost vigila esta cuenta en busca de publicaciones nuevas del formato de abajo.', + 'account_label' => 'Cuenta', + 'watch_label' => 'Vigilar', + 'needs_reconnect' => 'Necesita reconectarse', + ], + + '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' => [ + 'title' => 'Aún no hay ningún repurpose', + 'description' => 'TryPost sigue la cuenta que elijas y republica cada publicación nueva en las redes que marques.', + ], + + 'table' => [ + 'flow' => 'Flujo', + 'status' => 'Estado', + 'published' => 'Replicados', + 'last_polled' => 'Última comprobación', + ], + + 'status' => [ + 'draft' => 'Borrador', + 'active' => 'Activo', + 'paused' => 'En pausa', + 'disabled' => 'Desactivado', + ], + + 'create' => [ + 'title' => 'Nuevo repurpose', + 'description' => 'Elige la cuenta que TryPost debe vigilar. Los destinos se eligen en la siguiente pantalla.', + 'source_label' => 'Cuenta de origen', + 'source_placeholder' => 'Elige una cuenta', + 'source_search' => 'Buscar cuentas', + 'source_empty' => 'No se encontró ninguna cuenta.', + 'source_placeholder' => 'Selecciona una cuenta', + 'no_accounts' => 'Conecta antes una cuenta de Instagram o Facebook. Solo ellas pueden ser origen, porque son las únicas redes que permiten descargar el vídeo.', + 'submit' => 'Crear', + 'connect' => 'Conectar una cuenta', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => 'Guardando...', + 'saved' => 'Guardado', + ], + + 'tabs' => [ + 'configuration' => 'Configuración', + 'activity' => 'Actividad', + 'settings' => 'Ajustes', + ], + + 'destinations' => [ + 'paused_note' => 'Desactivadas y omitidas hasta que las reactives: :accounts', + 'title' => 'Destinos', + '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.', + 'publish_as' => 'Publicar como', + ], + + 'status_card' => [ + 'title' => 'Estado', + 'activate' => 'Activar', + 'pause' => 'Pausar', + 'resume' => 'Reanudar', + 'disable' => 'Desactivar', + 'watermark' => 'Vigilando desde', + 'last_polled' => 'Última comprobación', + '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.', + ], + + 'items' => [ + 'source' => 'Original', + 'published_at' => 'Publicado', + 'status' => 'Estado', + 'detail' => 'Detalle', + 'posts' => 'Replicado en', + 'view_original' => 'Ver original', + 'original_from' => 'original del :date', + 'empty' => [ + 'title' => 'Nada todavía', + 'description' => 'Las publicaciones que esta cuenta haga fuera de TryPost aparecerán aquí.', + ], + 'open_post' => 'Abrir publicación', + 'statuses' => [ + 'pending' => 'En cola', + 'processing' => 'Procesando', + 'published' => 'Replicado', + 'drafted' => 'Borrador', + 'skipped' => 'Omitido', + 'failed' => 'Falló', + ], + 'reasons' => [ + 'published_via_trypost' => 'Ya publicado con TryPost', + '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' => 'No había ningún destino disponible para publicar', + ], + ], + + '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.', + '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.', + 'source_unusable' => 'Vuelve a conectar la cuenta que observa esta automatización antes de iniciarla.', + 'destinations_required' => 'Elige al menos un destino antes de activar.', + 'destination_needs_video' => 'Ese formato no admite vídeo.', + 'only_paused_resumes' => 'Solo se puede reanudar un repurpose en pausa.', + 'only_active_pauses' => 'Solo se puede pausar un repurpose activo.', + 'only_running_disables' => 'Solo se puede desactivar un repurpose en marcha.', + 'only_idle_activates' => 'Solo se puede activar un borrador o un repurpose desactivado.', + 'destination_unavailable' => 'Esa cuenta de destino ya no está disponible.', + 'destination_is_source' => 'Ese destino es la misma cuenta que este repurpose observa.', + 'source_unavailable' => 'Esa cuenta de origen ya no está disponible.', + 'action_failed' => 'Algo salió mal. Revisa el formulario e inténtalo de nuevo.', + ], +]; diff --git a/lang/es/sidebar.php b/lang/es/sidebar.php index 185bc85c5..15d5061d5 100644 --- a/lang/es/sidebar.php +++ b/lang/es/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Otros', ], 'analytics' => 'Analytics', + 'repurposes' => 'Repurpose', 'onboarding' => 'Primeros pasos', 'onboarding_hint' => 'Termina la configuración', 'posts' => [ diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index 7ba02b5fe..b1581a078 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -123,6 +123,9 @@ ], '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 !', 'connected' => 'Compte connecté avec succès !', 'session_expired' => 'Session expirée. Veuillez réessayer.', 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 new file mode 100644 index 000000000..e3176200c --- /dev/null +++ b/lang/fr/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'Republiez automatiquement sur vos autres réseaux ce que vous postez en dehors de TryPost.', + 'new' => 'Nouveau repurpose', + + 'flow' => [ + 'no_source' => 'Aucun compte source', + 'no_destinations' => 'Aucune destination', + ], + + 'publish_mode' => [ + + 'title' => 'Publication', + + 'description' => 'Ce qui se passe quand une nouvelle publication apparaît.', + + ], + + 'publish_modes' => [ + + 'publish' => 'Publier automatiquement', + + 'publish_hint' => 'Chaque nouvelle publication est programmée dès qu\'elle est trouvée.', + + 'draft' => 'Créer en brouillon', + + 'draft_hint' => 'Chaque nouvelle publication devient un brouillon à relire et publier ici.', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => 'Vidéos', + 'story' => 'Stories', + ], + + 'source' => [ + 'title' => 'Source', + 'description' => 'TryPost surveille ce compte pour les nouvelles publications du format ci-dessous.', + 'account_label' => 'Compte', + 'watch_label' => 'Surveiller', + 'needs_reconnect' => 'Reconnexion nécessaire', + ], + + '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' => [ + 'title' => 'Aucun repurpose configuré', + 'description' => 'TryPost surveille le compte que vous choisissez et republie chaque nouvelle publication sur les réseaux sélectionnés.', + ], + + 'table' => [ + 'flow' => 'Flux', + 'status' => 'Statut', + 'published' => 'Répliquées', + 'last_polled' => 'Dernière vérification', + ], + + 'status' => [ + 'draft' => 'Brouillon', + 'active' => 'Actif', + 'paused' => 'En pause', + 'disabled' => 'Désactivé', + ], + + 'create' => [ + 'title' => 'Nouveau repurpose', + 'description' => 'Choisissez le compte que TryPost doit surveiller. Les destinations se choisissent à l\'écran suivant.', + 'source_label' => 'Compte source', + 'source_placeholder' => 'Choisissez un compte', + 'source_search' => 'Rechercher des comptes', + 'source_empty' => 'Aucun compte trouvé.', + 'source_placeholder' => 'Sélectionner un compte', + 'no_accounts' => 'Connectez d\'abord un compte Instagram ou Facebook. Seuls ces réseaux peuvent être source, car ce sont les seuls qui permettent de télécharger la vidéo.', + 'submit' => 'Créer', + 'connect' => 'Connecter un compte', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => 'Enregistrement...', + 'saved' => 'Enregistré', + ], + + 'tabs' => [ + 'configuration' => 'Configuration', + 'activity' => 'Activité', + 'settings' => 'Réglages', + ], + + 'destinations' => [ + 'paused_note' => 'Désactivés et ignorés jusqu\'à réactivation : :accounts', + 'title' => 'Destinations', + '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.', + 'publish_as' => 'Publier comme', + ], + + 'status_card' => [ + 'title' => 'Statut', + 'activate' => 'Activer', + 'pause' => 'Mettre en pause', + 'resume' => 'Reprendre', + 'disable' => 'Désactiver', + 'watermark' => 'Surveillé depuis', + 'last_polled' => 'Dernière vérification', + '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é.', + ], + + 'items' => [ + 'source' => 'Original', + 'published_at' => 'Publié', + 'status' => 'Statut', + 'detail' => 'Détail', + 'posts' => 'Répliqué sur', + 'view_original' => 'Voir l\'original', + 'original_from' => 'original du :date', + 'empty' => [ + 'title' => 'Rien pour l\'instant', + 'description' => 'Les publications de ce compte hors de TryPost apparaîtront ici.', + ], + 'open_post' => 'Ouvrir la publication', + 'statuses' => [ + 'pending' => 'En file d\'attente', + 'processing' => 'Traitement', + 'published' => 'Répliqué', + 'drafted' => 'Brouillon', + 'skipped' => 'Ignoré', + 'failed' => 'Échec', + ], + 'reasons' => [ + 'published_via_trypost' => 'Déjà publié via TryPost', + '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' => 'Aucune destination n\'était disponible pour publier', + ], + ], + + '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.', + '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.', + 'source_unusable' => 'Reconnectez le compte que cette automatisation surveille avant de la démarrer.', + 'destinations_required' => 'Choisissez au moins une destination avant d\'activer.', + 'destination_needs_video' => 'Ce format n\'accepte pas de vidéo.', + 'only_paused_resumes' => 'Seul un repurpose en pause peut être repris.', + 'only_active_pauses' => 'Seul un repurpose actif peut être mis en pause.', + 'only_running_disables' => 'Seul un repurpose en cours peut être désactivé.', + 'only_idle_activates' => 'Seul un brouillon ou un repurpose désactivé peut être activé.', + 'destination_unavailable' => 'Ce compte de destination n\'est plus disponible.', + 'destination_is_source' => 'Cette destination est le compte que ce repurpose surveille.', + 'source_unavailable' => 'Ce compte source n\'est plus disponible.', + 'action_failed' => 'Une erreur est survenue. Vérifiez le formulaire et réessayez.', + ], +]; diff --git a/lang/fr/sidebar.php b/lang/fr/sidebar.php index a5eac5465..ba4c6f791 100644 --- a/lang/fr/sidebar.php +++ b/lang/fr/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Autres', ], 'analytics' => 'Statistiques', + 'repurposes' => 'Repurpose', 'onboarding' => 'Premiers pas', 'onboarding_hint' => 'Terminer la configuration', 'posts' => [ diff --git a/lang/it/accounts.php b/lang/it/accounts.php index 20d2deb5f..525ebc5eb 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -123,6 +123,9 @@ ], '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!', 'connected' => 'Account collegato con successo!', 'session_expired' => 'Sessione scaduta. Riprova.', 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 new file mode 100644 index 000000000..ac2dfcc15 --- /dev/null +++ b/lang/it/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'Ripubblica automaticamente sulle altre reti ciò che pubblichi fuori da TryPost.', + 'new' => 'Nuovo repurpose', + + 'flow' => [ + 'no_source' => 'Nessun account di origine', + 'no_destinations' => 'Nessuna destinazione', + ], + + 'publish_mode' => [ + + 'title' => 'Pubblicazione', + + 'description' => 'Cosa succede quando compare un nuovo post.', + + ], + + 'publish_modes' => [ + + 'publish' => 'Pubblica automaticamente', + + 'publish_hint' => 'Ogni nuovo post viene programmato appena viene trovato.', + + 'draft' => 'Crea come bozza', + + 'draft_hint' => 'Ogni nuovo post diventa una bozza da rivedere e pubblicare qui.', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => 'Video', + 'story' => 'Storie', + ], + + 'source' => [ + 'title' => 'Origine', + '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', + ], + + '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' => [ + 'title' => 'Nessun repurpose configurato', + 'description' => 'TryPost monitora l\'account che scegli e ripubblica ogni nuovo post sulle reti selezionate.', + ], + + 'table' => [ + 'flow' => 'Flusso', + 'status' => 'Stato', + 'published' => 'Replicati', + 'last_polled' => 'Ultimo controllo', + ], + + 'status' => [ + 'draft' => 'Bozza', + 'active' => 'Attivo', + 'paused' => 'In pausa', + 'disabled' => 'Disattivato', + ], + + 'create' => [ + 'title' => 'Nuovo repurpose', + 'description' => 'Scegli l\'account che TryPost deve seguire. Le destinazioni si scelgono nella schermata successiva.', + 'source_label' => 'Account di origine', + 'source_placeholder' => 'Scegli un account', + 'source_search' => 'Cerca account', + 'source_empty' => 'Nessun account trovato.', + 'source_placeholder' => 'Seleziona un account', + 'no_accounts' => 'Collega prima un account Instagram o Facebook. Solo questi possono essere origine, perché sono le uniche reti che permettono di scaricare il video.', + 'submit' => 'Crea', + 'connect' => 'Collega un account', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => 'Salvataggio in corso...', + 'saved' => 'Salvato', + ], + + 'tabs' => [ + 'configuration' => 'Configurazione', + 'activity' => 'Attività', + 'settings' => 'Impostazioni', + ], + + 'destinations' => [ + 'paused_note' => 'Disattivati e ignorati finché non li riattivi: :accounts', + 'title' => 'Destinazioni', + '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.', + 'publish_as' => 'Pubblica come', + ], + + 'status_card' => [ + 'title' => 'Stato', + 'activate' => 'Attiva', + 'pause' => 'Metti in pausa', + 'resume' => 'Riprendi', + 'disable' => 'Disattiva', + 'watermark' => 'In ascolto da', + 'last_polled' => 'Ultimo controllo', + '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.', + ], + + 'items' => [ + 'source' => 'Originale', + 'published_at' => 'Pubblicato', + 'status' => 'Stato', + 'detail' => 'Dettaglio', + 'posts' => 'Replicato su', + 'view_original' => 'Vedi originale', + 'original_from' => 'originale del :date', + 'empty' => [ + 'title' => 'Ancora niente', + 'description' => 'I post che questo account pubblica fuori da TryPost appariranno qui.', + ], + 'open_post' => 'Apri post', + 'statuses' => [ + 'pending' => 'In coda', + 'processing' => 'In elaborazione', + 'published' => 'Replicato', + 'drafted' => 'Bozza', + 'skipped' => 'Ignorato', + 'failed' => 'Non riuscito', + ], + 'reasons' => [ + 'published_via_trypost' => 'Già pubblicato tramite TryPost', + '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' => 'Nessuna destinazione era disponibile per pubblicare', + ], + ], + + 'menu' => [ + + 'label' => 'Altre azioni', + + ], + + 'danger' => [ + 'title' => 'Elimina questo repurpose', + 'description' => 'I controlli si fermano subito. I post già creati restano nel tuo calendario.', + '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.', + 'source_unusable' => 'Riconnetti l\'account monitorato prima di avviare questa automazione.', + 'destinations_required' => 'Scegli almeno una destinazione prima di attivare.', + 'destination_needs_video' => 'Quel formato non accetta video.', + 'only_paused_resumes' => 'Solo un repurpose in pausa può essere ripreso.', + 'only_active_pauses' => 'Solo un repurpose attivo può essere messo in pausa.', + 'only_running_disables' => 'Solo un repurpose in esecuzione può essere disattivato.', + 'only_idle_activates' => 'Solo una bozza o un repurpose disattivato può essere attivato.', + 'destination_unavailable' => 'Quell\'account di destinazione non è più disponibile.', + 'destination_is_source' => 'Quella destinazione è l\'account che questo repurpose osserva.', + 'source_unavailable' => 'Quell\'account di origine non è più disponibile.', + 'action_failed' => 'Qualcosa è andato storto. Controlla il modulo e riprova.', + ], +]; diff --git a/lang/it/sidebar.php b/lang/it/sidebar.php index 55c1e47aa..ce611fe30 100644 --- a/lang/it/sidebar.php +++ b/lang/it/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Altro', ], 'analytics' => 'Statistiche', + 'repurposes' => 'Repurpose', 'onboarding' => 'Primi passi', 'onboarding_hint' => 'Completa la configurazione', 'posts' => [ diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index 23f151420..b0ff0cf5a 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -123,6 +123,9 @@ ], 'flash' => [ + 'activated_resumed_repurposes' => 'アカウントを有効にしました。:count 件の自動化を再開しました。|アカウントを有効にしました。:count 件の自動化を再開しました。', + 'disconnected_paused_repurposes' => 'アカウントを切断しました。:count 件の自動化を停止しました。|アカウントを切断しました。:count 件の自動化を停止しました。', + 'deactivated_paused_repurposes' => 'アカウントを無効にしました。:count 件の自動化を停止しました。|アカウントを無効にしました。:count 件の自動化を停止しました。', 'disconnected' => 'アカウントの接続を解除しました!', 'connected' => 'アカウントを接続しました!', 'session_expired' => 'セッションの有効期限が切れました。もう一度お試しください。', 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 new file mode 100644 index 000000000..2987b48df --- /dev/null +++ b/lang/ja/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'TryPost の外で投稿したものを、他のネットワークへ自動で再投稿します。', + 'new' => '新しい Repurpose', + + 'flow' => [ + 'no_source' => 'ソースアカウントなし', + 'no_destinations' => '配信先はまだありません', + ], + + 'publish_mode' => [ + + 'title' => '公開', + + 'description' => '新しい投稿が見つかったときの動作。', + + ], + + 'publish_modes' => [ + + 'publish' => '自動的に公開', + + 'publish_hint' => '新しい投稿は見つかった時点で予約されます。', + + 'draft' => '下書きとして作成', + + 'draft_hint' => '新しい投稿はここで下書きになり、確認してから公開できます。', + + ], + + 'formats' => [ + 'reel' => 'リール', + 'video' => '動画', + 'story' => 'ストーリーズ', + ], + + 'source' => [ + 'title' => 'ソース', + 'description' => 'TryPost がこのアカウントを見張り、下で選んだ形式の新しい投稿を探します。', + 'account_label' => 'アカウント', + 'watch_label' => '監視する形式', + 'needs_reconnect' => '再接続が必要', + ], + + 'summary' => [ + 'sentence' => ':source に新しい :format を投稿するたびに、:destinations へ再投稿されます。', + 'no_destinations' => ':source に投稿する新しい :format は、まだ配信先を待っています。', + 'no_source' => 'この自動化にはソースアカウントがありません。再開するには選択してください。', + ], + + 'empty' => [ + 'title' => 'Repurpose はまだ設定されていません', + 'description' => 'TryPost は選んだアカウントを監視し、新しい投稿を指定したネットワークに再投稿します。', + ], + + 'table' => [ + 'flow' => 'フロー', + 'status' => 'ステータス', + 'published' => '再投稿済み', + 'last_polled' => '最終チェック', + ], + + 'status' => [ + 'draft' => '下書き', + 'active' => '有効', + 'paused' => '一時停止', + 'disabled' => '無効', + ], + + 'create' => [ + 'title' => '新しい Repurpose', + 'description' => 'TryPost が見張るアカウントを選んでください。配信先は次の画面で選びます。', + 'source_label' => 'ソースアカウント', + 'source_placeholder' => 'アカウントを選択', + 'source_search' => 'アカウントを検索', + 'source_empty' => 'アカウントが見つかりません。', + 'source_placeholder' => 'アカウントを選択', + 'no_accounts' => '先に Instagram か Facebook のアカウントを接続してください。動画をダウンロードできるのはこの 2 つだけなので、ソースになれるのもこの 2 つだけです。', + 'submit' => '作成', + 'connect' => 'アカウントを接続', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => '保存中...', + 'saved' => '保存しました', + ], + + 'tabs' => [ + 'configuration' => '設定', + 'activity' => 'アクティビティ', + 'settings' => '設定', + ], + + 'destinations' => [ + 'paused_note' => 'オフのためスキップされます。再度オンにするまで: :accounts', + 'title' => '配信先', + 'description' => '受け取るアカウントを選びます。それぞれ、指定した形式で投稿します。', + 'hint' => 'キャプションは、そのネットワークの上限を超えたときだけ調整されます。', + 'none_available' => 'このワークスペースにはまだ他のアカウントが接続されていません。', + 'publish_as' => '投稿形式', + ], + + 'status_card' => [ + 'title' => 'ステータス', + 'activate' => '有効にする', + 'pause' => '一時停止', + 'resume' => '再開', + 'disable' => '無効にする', + 'watermark' => '監視開始', + 'last_polled' => '最終チェック', + 'draft_hint' => '宛先を1つ以上選んでから有効にしてください。有効化した後の投稿だけが複製されます。', + 'active_hint' => 'TryPost はこのアカウントを定期的に確認し、新しい投稿をすべて複製します。', + 'paused_hint' => 'チェックを停止中です。再開すると止まった時点から続き、その間の投稿も失われません。', + 'disabled_hint' => 'オフです。もう一度有効にすると最初からになり、オフの間に投稿したものは対象外のままです。', + ], + + 'items' => [ + 'source' => 'オリジナル', + 'published_at' => '投稿日', + 'status' => 'ステータス', + 'detail' => '詳細', + 'posts' => '再投稿先', + 'view_original' => 'オリジナルを見る', + 'original_from' => '元投稿 :date', + 'empty' => [ + 'title' => 'まだ何もありません', + 'description' => 'このアカウントが TryPost の外で公開した投稿がここに表示されます。', + ], + 'open_post' => '投稿を開く', + 'statuses' => [ + 'pending' => '待機中', + 'processing' => '処理中', + 'published' => '再投稿済み', + 'drafted' => '下書き', + 'skipped' => 'スキップ', + 'failed' => '失敗', + ], + 'reasons' => [ + 'published_via_trypost' => 'すでに TryPost から投稿済み', + 'media_url_missing' => 'ネットワークがダウンロード可能なファイルを返しませんでした。多くは著作権付き音源が原因です', + 'download_failed' => '動画をダウンロードできませんでした', + 'post_creation_failed' => '投稿を作成できませんでした', + 'no_usable_destinations' => '公開できる配信先がありませんでした', + ], + ], + + 'menu' => [ + + 'label' => 'その他の操作', + + ], + + 'danger' => [ + 'title' => 'この Repurpose を削除', + 'description' => 'チェックはすぐに止まります。作成済みの投稿はカレンダーに残ります。', + 'delete' => 'Repurpose を削除', + ], + + 'health' => [ + 'stopped_itself' => '自動的に停止しました。開いて理由を確認してください', + 'source_missing' => '複製は停止中です。この自動化にはソースアカウントがありません。選択してから再開してください。', + 'source_unusable' => '複製は停止中です。監視対象のアカウントを再接続してください。', + 'no_destinations' => '複製は停止中です。利用できる配信先がありません。追加してから再開してください。', + 'ready' => '問題は解消しました。この自動化を再開すると複製が再び始まります。', + ], + + 'errors' => [ + 'source_already_used' => 'このアカウントはすでに別の Repurpose で使われています。そちらを編集してください。', + 'source_missing' => 'この自動化を開始する前に、監視するアカウントを選択してください。', + 'source_unusable' => 'この自動化を開始する前に、監視対象のアカウントを再接続してください。', + 'destinations_required' => '有効にする前に配信先を 1 つ以上選んでください。', + 'destination_needs_video' => 'その形式は動画に対応していません。', + 'only_paused_resumes' => '再開できるのは一時停止中の Repurpose だけです。', + 'only_active_pauses' => '一時停止できるのは有効なリパーパスだけです。', + 'only_running_disables' => '無効にできるのは稼働中のリパーパスだけです。', + 'only_idle_activates' => '有効にできるのは下書きまたは無効なリパーパスだけです。', + 'destination_unavailable' => 'その配信先アカウントは利用できなくなりました。', + 'destination_is_source' => 'その配信先は、このリパーパスが監視しているアカウント自身です。', + 'source_unavailable' => 'そのソースアカウントは利用できなくなりました。', + 'action_failed' => '問題が発生しました。入力内容を確認してもう一度お試しください。', + ], +]; diff --git a/lang/ja/sidebar.php b/lang/ja/sidebar.php index a333f21a3..2613a93e5 100644 --- a/lang/ja/sidebar.php +++ b/lang/ja/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'その他', ], 'analytics' => 'アナリティクス', + 'repurposes' => 'Repurpose', 'onboarding' => 'はじめに', 'onboarding_hint' => 'セットアップを完了', 'posts' => [ diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 48c2c06cc..61946bd3e 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -123,6 +123,9 @@ ], 'flash' => [ + 'activated_resumed_repurposes' => '계정을 켰습니다. 자동화 :count개를 재개했습니다.|계정을 켰습니다. 자동화 :count개를 재개했습니다.', + 'disconnected_paused_repurposes' => '계정 연결을 해제했습니다. 자동화 :count개를 중단했습니다.|계정 연결을 해제했습니다. 자동화 :count개를 중단했습니다.', + 'deactivated_paused_repurposes' => '계정을 껐습니다. 자동화 :count개를 중단했습니다.|계정을 껐습니다. 자동화 :count개를 중단했습니다.', 'disconnected' => '계정 연결이 해제되었습니다!', 'connected' => '계정이 연결되었습니다!', 'session_expired' => '세션이 만료되었습니다. 다시 시도해 주세요.', 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 new file mode 100644 index 000000000..15a9f5196 --- /dev/null +++ b/lang/ko/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'TryPost 외부에서 올린 것을 다른 네트워크에 자동으로 다시 게시합니다.', + 'new' => '새 Repurpose', + + 'flow' => [ + 'no_source' => '소스 계정 없음', + 'no_destinations' => '아직 대상이 없습니다', + ], + + 'publish_mode' => [ + + 'title' => '게시', + + 'description' => '새 게시물이 나타났을 때의 동작.', + + ], + + 'publish_modes' => [ + + 'publish' => '자동으로 게시', + + 'publish_hint' => '새 게시물은 발견되는 즉시 예약됩니다.', + + 'draft' => '초안으로 만들기', + + 'draft_hint' => '새 게시물은 여기에서 초안이 되어 검토 후 게시할 수 있습니다.', + + ], + + 'formats' => [ + 'reel' => '릴스', + 'video' => '동영상', + 'story' => '스토리', + ], + + 'source' => [ + 'title' => '소스', + 'description' => 'TryPost가 이 계정에서 아래 형식의 새 게시물을 지켜봅니다.', + 'account_label' => '계정', + 'watch_label' => '감시할 형식', + 'needs_reconnect' => '다시 연결해야 함', + ], + + 'summary' => [ + 'sentence' => ':source에 새 :format을 올릴 때마다 :destinations에 다시 게시됩니다.', + 'no_destinations' => ':source에 올리는 새 :format이 아직 대상을 기다리고 있습니다.', + 'no_source' => '이 자동화에는 소스 계정이 없습니다. 다시 시작하려면 계정을 선택하세요.', + ], + + 'empty' => [ + 'title' => '아직 설정된 Repurpose가 없습니다', + 'description' => 'TryPost가 선택한 계정을 지켜보고 새 게시물을 지정한 네트워크에 다시 게시합니다.', + ], + + 'table' => [ + 'flow' => '흐름', + 'status' => '상태', + 'published' => '복제됨', + 'last_polled' => '마지막 확인', + ], + + 'status' => [ + 'draft' => '초안', + 'active' => '활성', + 'paused' => '일시중지', + 'disabled' => '비활성', + ], + + 'create' => [ + 'title' => '새 Repurpose', + 'description' => 'TryPost가 지켜볼 계정을 고르세요. 대상은 다음 화면에서 선택합니다.', + 'source_label' => '소스 계정', + 'source_placeholder' => '계정 선택', + 'source_search' => '계정 검색', + 'source_empty' => '계정을 찾을 수 없습니다.', + 'source_placeholder' => '계정 선택', + 'no_accounts' => '먼저 Instagram이나 Facebook 계정을 연결하세요. 영상을 내려받을 수 있는 네트워크는 이 둘뿐이라 소스도 이 둘만 가능합니다.', + 'submit' => '만들기', + 'connect' => '계정 연결', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => '저장 중...', + 'saved' => '저장됨', + ], + + 'tabs' => [ + 'configuration' => '설정', + 'activity' => '활동', + 'settings' => '설정', + ], + + 'destinations' => [ + 'paused_note' => '꺼져 있어 건너뜁니다. 다시 켤 때까지: :accounts', + 'title' => '대상', + 'description' => '받을 계정을 고르세요. 각 계정은 지정한 형식으로 게시합니다.', + 'hint' => '캡션은 해당 네트워크의 한도를 넘을 때만 조정됩니다.', + 'none_available' => '이 워크스페이스에 연결된 다른 계정이 아직 없습니다.', + 'publish_as' => '게시 형식', + ], + + 'status_card' => [ + 'title' => '상태', + 'activate' => '활성화', + 'pause' => '일시중지', + 'resume' => '재개', + 'disable' => '비활성화', + 'watermark' => '확인 시작', + 'last_polled' => '마지막 확인', + 'draft_hint' => '대상을 하나 이상 고른 뒤 활성화하세요. 활성화 이후에 올린 게시물만 복제됩니다.', + 'active_hint' => 'TryPost가 이 계정을 주기적으로 확인하고 새 게시물을 모두 복제합니다.', + 'paused_hint' => '확인이 멈춰 있습니다. 재개하면 멈춘 지점부터 이어지며 그동안 올린 것도 잃지 않습니다.', + 'disabled_hint' => '꺼져 있습니다. 다시 활성화하면 처음부터 시작하며, 꺼져 있는 동안 올린 것은 제외됩니다.', + ], + + 'items' => [ + 'source' => '원본', + 'published_at' => '게시일', + 'status' => '상태', + 'detail' => '상세', + 'posts' => '복제 대상', + 'view_original' => '원본 보기', + 'original_from' => '원본 :date', + 'empty' => [ + 'title' => '아직 없음', + 'description' => '이 계정이 TryPost 밖에서 올린 게시물이 여기에 표시됩니다.', + ], + 'open_post' => '게시물 열기', + 'statuses' => [ + 'pending' => '대기 중', + 'processing' => '처리 중', + 'published' => '복제됨', + 'drafted' => '초안', + 'skipped' => '건너뜀', + 'failed' => '실패', + ], + 'reasons' => [ + 'published_via_trypost' => '이미 TryPost로 게시됨', + 'media_url_missing' => '네트워크가 내려받을 수 있는 파일을 제공하지 않았습니다. 보통 저작권 오디오 때문입니다', + 'download_failed' => '영상을 내려받지 못했습니다', + 'post_creation_failed' => '게시물을 만들지 못했습니다', + 'no_usable_destinations' => '게시할 수 있는 대상이 없었습니다', + ], + ], + + 'menu' => [ + + 'label' => '추가 작업', + + ], + + 'danger' => [ + 'title' => '이 Repurpose 삭제', + 'description' => '확인이 즉시 중단됩니다. 이미 만들어진 게시물은 캘린더에 남습니다.', + 'delete' => 'Repurpose 삭제', + ], + + 'health' => [ + 'stopped_itself' => '자동으로 중단되었습니다 — 열어서 이유를 확인하세요', + 'source_missing' => '복제가 중단되었습니다. 이 자동화에는 소스 계정이 없습니다. 계정을 선택한 뒤 재개하세요.', + 'source_unusable' => '복제가 중단되었습니다. 모니터링 중인 계정을 다시 연결해야 합니다.', + 'no_destinations' => '복제가 중단되었습니다. 사용 가능한 대상이 없습니다. 대상을 추가한 뒤 재개하세요.', + 'ready' => '문제가 해결되었습니다. 이 자동화를 재개하면 복제가 다시 시작됩니다.', + ], + + 'errors' => [ + 'source_already_used' => '이 계정은 이미 다른 Repurpose에 쓰이고 있습니다. 그것을 수정하세요.', + 'source_missing' => '이 자동화를 시작하기 전에 모니터링할 계정을 선택하세요.', + 'source_unusable' => '이 자동화를 시작하기 전에 모니터링 중인 계정을 다시 연결하세요.', + 'destinations_required' => '활성화하기 전에 대상을 하나 이상 고르세요.', + 'destination_needs_video' => '그 형식은 영상을 담을 수 없습니다.', + 'only_paused_resumes' => '일시중지된 Repurpose만 재개할 수 있습니다.', + 'only_active_pauses' => '활성 상태의 리퍼포즈만 일시중지할 수 있습니다.', + 'only_running_disables' => '실행 중인 리퍼포즈만 사용 중지할 수 있습니다.', + 'only_idle_activates' => '초안이거나 사용 중지된 리퍼포즈만 활성화할 수 있습니다.', + 'destination_unavailable' => '해당 대상 계정을 더 이상 사용할 수 없습니다.', + 'destination_is_source' => '해당 대상은 이 리퍼포즈가 감시 중인 계정입니다.', + 'source_unavailable' => '해당 소스 계정은 더 이상 사용할 수 없습니다.', + 'action_failed' => '문제가 발생했습니다. 입력을 확인하고 다시 시도하세요.', + ], +]; diff --git a/lang/ko/sidebar.php b/lang/ko/sidebar.php index 2fcb2cbc3..89f83b9ed 100644 --- a/lang/ko/sidebar.php +++ b/lang/ko/sidebar.php @@ -26,6 +26,7 @@ 'others' => '기타', ], 'analytics' => '분석', + 'repurposes' => 'Repurpose', 'onboarding' => '시작하기', 'onboarding_hint' => '설정 마치기', 'posts' => [ diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index 835ec17d1..d15f68800 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -123,6 +123,9 @@ ], '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!', 'connected' => 'Account succesvol gekoppeld!', 'session_expired' => 'Sessie verlopen. Probeer het opnieuw.', 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 new file mode 100644 index 000000000..83d929fb7 --- /dev/null +++ b/lang/nl/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'Publiceer wat je buiten TryPost post automatisch opnieuw op je andere netwerken.', + 'new' => 'Nieuwe repurpose', + + 'flow' => [ + 'no_source' => 'Geen bronaccount', + 'no_destinations' => 'Nog geen bestemming', + ], + + 'publish_mode' => [ + + 'title' => 'Publiceren', + + 'description' => 'Wat er gebeurt als er een nieuw bericht verschijnt.', + + ], + + 'publish_modes' => [ + + 'publish' => 'Automatisch publiceren', + + 'publish_hint' => 'Elk nieuw bericht wordt ingepland zodra dat gevonden is.', + + 'draft' => 'Als concept aanmaken', + + 'draft_hint' => 'Elk nieuw bericht wordt hier een concept om na te kijken en te publiceren.', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => 'Video\'s', + 'story' => 'Stories', + ], + + 'source' => [ + 'title' => 'Bron', + 'description' => 'TryPost volgt dit account op nieuwe berichten van het formaat hieronder.', + 'account_label' => 'Account', + 'watch_label' => 'Volgen', + 'needs_reconnect' => 'Opnieuw verbinden nodig', + ], + + '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' => [ + 'title' => 'Nog geen repurpose ingesteld', + 'description' => 'TryPost volgt het account dat je kiest en plaatst elk nieuw bericht opnieuw op de netwerken die je aanvinkt.', + ], + + 'table' => [ + 'flow' => 'Stroom', + 'status' => 'Status', + 'published' => 'Gerepliceerd', + 'last_polled' => 'Laatst gecontroleerd', + ], + + 'status' => [ + 'draft' => 'Concept', + 'active' => 'Actief', + 'paused' => 'Gepauzeerd', + 'disabled' => 'Uitgeschakeld', + ], + + 'create' => [ + 'title' => 'Nieuwe repurpose', + 'description' => 'Kies het account dat TryPost moet volgen. De bestemmingen kies je in het volgende scherm.', + 'source_label' => 'Bronaccount', + 'source_placeholder' => 'Kies een account', + 'source_search' => 'Accounts zoeken', + 'source_empty' => 'Geen account gevonden.', + 'source_placeholder' => 'Selecteer een account', + 'no_accounts' => 'Koppel eerst een Instagram- of Facebook-account. Alleen die kunnen bron zijn, want alleen zij laten ons de video downloaden.', + 'submit' => 'Aanmaken', + 'connect' => 'Account koppelen', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => 'Opslaan...', + 'saved' => 'Opgeslagen', + ], + + 'tabs' => [ + 'configuration' => 'Configuratie', + 'activity' => 'Activiteit', + 'settings' => 'Instellingen', + ], + + 'destinations' => [ + 'paused_note' => 'Uitgeschakeld en overgeslagen tot je ze weer aanzet: :accounts', + 'title' => 'Bestemmingen', + '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.', + 'publish_as' => 'Plaatsen als', + ], + + 'status_card' => [ + 'title' => 'Status', + 'activate' => 'Activeren', + 'pause' => 'Pauzeren', + 'resume' => 'Hervatten', + 'disable' => 'Uitschakelen', + 'watermark' => 'Gevolgd sinds', + 'last_polled' => 'Laatst gecontroleerd', + '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.', + ], + + 'items' => [ + 'source' => 'Origineel', + 'published_at' => 'Geplaatst', + 'status' => 'Status', + 'detail' => 'Detail', + 'posts' => 'Gerepliceerd naar', + 'view_original' => 'Origineel bekijken', + 'original_from' => 'origineel van :date', + 'empty' => [ + 'title' => 'Nog niets', + 'description' => 'Berichten die dit account buiten TryPost plaatst, verschijnen hier.', + ], + 'open_post' => 'Post openen', + 'statuses' => [ + 'pending' => 'In wachtrij', + 'processing' => 'Bezig', + 'published' => 'Gerepliceerd', + 'drafted' => 'Concept', + 'skipped' => 'Overgeslagen', + 'failed' => 'Mislukt', + ], + 'reasons' => [ + 'published_via_trypost' => 'Al gepubliceerd via TryPost', + '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' => 'Geen bestemming beschikbaar om naar te publiceren', + ], + ], + + 'menu' => [ + + 'label' => 'Meer acties', + + ], + + 'danger' => [ + 'title' => 'Deze repurpose verwijderen', + 'description' => 'De controles stoppen onmiddellijk. Al gemaakte posts blijven in je kalender staan.', + '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.', + 'source_unusable' => 'Verbind het gevolgde account opnieuw voordat je deze automatisering start.', + 'destinations_required' => 'Kies minstens één bestemming voordat je activeert.', + 'destination_needs_video' => 'Dat formaat kan geen video bevatten.', + 'only_paused_resumes' => 'Alleen een gepauzeerde repurpose kan worden hervat.', + 'only_active_pauses' => 'Alleen een actieve repurpose kan worden gepauzeerd.', + 'only_running_disables' => 'Alleen een lopende repurpose kan worden uitgeschakeld.', + 'only_idle_activates' => 'Alleen een concept of uitgeschakelde repurpose kan worden geactiveerd.', + 'destination_unavailable' => 'Dat bestemmingsaccount is niet meer beschikbaar.', + 'destination_is_source' => 'Die bestemming is het account dat deze repurpose volgt.', + 'source_unavailable' => 'Dat bronaccount is niet langer beschikbaar.', + 'action_failed' => 'Er ging iets mis. Controleer het formulier en probeer opnieuw.', + ], +]; diff --git a/lang/nl/sidebar.php b/lang/nl/sidebar.php index adce46db0..5a59e747a 100644 --- a/lang/nl/sidebar.php +++ b/lang/nl/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Overige', ], 'analytics' => 'Statistieken', + 'repurposes' => 'Repurpose', 'onboarding' => 'Aan de slag', 'onboarding_hint' => 'Setup afronden', 'posts' => [ diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index 0d5b98a82..e9e1ebdbd 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -123,6 +123,9 @@ ], '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!', 'connected' => 'Konto zostało pomyślnie połączone!', 'session_expired' => 'Sesja wygasła. Spróbuj ponownie.', 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 new file mode 100644 index 000000000..306b4ed2b --- /dev/null +++ b/lang/pl/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'Automatycznie publikuj w pozostałych sieciach to, co wrzucasz poza TryPost.', + 'new' => 'Nowy repurpose', + + 'flow' => [ + 'no_source' => 'Brak konta źródłowego', + 'no_destinations' => 'Brak celu', + ], + + 'publish_mode' => [ + + 'title' => 'Publikowanie', + + 'description' => 'Co się dzieje, gdy pojawia się nowy post.', + + ], + + 'publish_modes' => [ + + 'publish' => 'Publikuj automatycznie', + + 'publish_hint' => 'Każdy nowy post jest planowany zaraz po znalezieniu.', + + 'draft' => 'Utwórz jako wersję roboczą', + + 'draft_hint' => 'Każdy nowy post trafia tu jako wersja robocza do sprawdzenia i publikacji.', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => 'Filmy', + 'story' => 'Stories', + ], + + 'source' => [ + 'title' => 'Źródło', + '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', + ], + + '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' => [ + 'title' => 'Nie skonfigurowano jeszcze repurpose', + 'description' => 'TryPost obserwuje wybrane konto i publikuje każdy nowy post w zaznaczonych sieciach.', + ], + + 'table' => [ + 'flow' => 'Przepływ', + 'status' => 'Status', + 'published' => 'Zreplikowane', + 'last_polled' => 'Ostatnie sprawdzenie', + ], + + 'status' => [ + 'draft' => 'Szkic', + 'active' => 'Aktywny', + 'paused' => 'Wstrzymany', + 'disabled' => 'Wyłączony', + ], + + 'create' => [ + 'title' => 'Nowy repurpose', + 'description' => 'Wybierz konto, które TryPost ma obserwować. Cele wybierzesz na następnym ekranie.', + 'source_label' => 'Konto źródłowe', + 'source_placeholder' => 'Wybierz konto', + 'source_search' => 'Szukaj kont', + 'source_empty' => 'Nie znaleziono konta.', + 'source_placeholder' => 'Wybierz konto', + 'no_accounts' => 'Najpierw połącz konto Instagrama lub Facebooka. Tylko one mogą być źródłem, bo tylko te sieci pozwalają pobrać film.', + 'submit' => 'Utwórz', + 'connect' => 'Połącz konto', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => 'Zapisywanie...', + 'saved' => 'Zapisano', + ], + + 'tabs' => [ + 'configuration' => 'Konfiguracja', + 'activity' => 'Aktywność', + 'settings' => 'Ustawienia', + ], + + 'destinations' => [ + 'paused_note' => 'Wyłączone i pomijane do czasu ponownego włączenia: :accounts', + 'title' => 'Cele', + '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.', + 'publish_as' => 'Publikuj jako', + ], + + 'status_card' => [ + 'title' => 'Status', + 'activate' => 'Aktywuj', + 'pause' => 'Wstrzymaj', + 'resume' => 'Wznów', + 'disable' => 'Wyłącz', + 'watermark' => 'Obserwuje od', + 'last_polled' => 'Ostatnie sprawdzenie', + '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.', + ], + + 'items' => [ + 'source' => 'Oryginał', + 'published_at' => 'Opublikowano', + 'status' => 'Status', + 'detail' => 'Szczegół', + 'posts' => 'Zreplikowano do', + 'view_original' => 'Zobacz oryginał', + 'original_from' => 'oryginał z :date', + 'empty' => [ + 'title' => 'Jeszcze nic', + 'description' => 'Posty publikowane przez to konto poza TryPost pojawią się tutaj.', + ], + 'open_post' => 'Otwórz post', + 'statuses' => [ + 'pending' => 'W kolejce', + 'processing' => 'Przetwarzanie', + 'published' => 'Zreplikowano', + 'drafted' => 'Wersja robocza', + 'skipped' => 'Pominięto', + 'failed' => 'Niepowodzenie', + ], + 'reasons' => [ + 'published_via_trypost' => 'Już opublikowane przez TryPost', + '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' => 'Brak dostępnego miejsca docelowego do publikacji', + ], + ], + + 'menu' => [ + + 'label' => 'Więcej akcji', + + ], + + 'danger' => [ + 'title' => 'Usuń ten repurpose', + 'description' => 'Sprawdzanie zatrzyma się natychmiast. Utworzone już posty pozostaną w kalendarzu.', + '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.', + 'source_unusable' => 'Połącz ponownie monitorowane konto przed uruchomieniem tej automatyzacji.', + 'destinations_required' => 'Wybierz co najmniej jeden cel przed aktywacją.', + 'destination_needs_video' => 'Ten format nie przyjmuje filmu.', + 'only_paused_resumes' => 'Wznowić można tylko wstrzymany repurpose.', + 'only_active_pauses' => 'Tylko aktywny repurpose można wstrzymać.', + 'only_running_disables' => 'Tylko działający repurpose można wyłączyć.', + 'only_idle_activates' => 'Tylko wersję roboczą lub wyłączony repurpose można aktywować.', + 'destination_unavailable' => 'To konto docelowe nie jest już dostępne.', + 'destination_is_source' => 'Ten cel to konto obserwowane przez ten repurpose.', + 'source_unavailable' => 'To konto źródłowe nie jest już dostępne.', + 'action_failed' => 'Coś poszło nie tak. Sprawdź formularz i spróbuj ponownie.', + ], +]; diff --git a/lang/pl/sidebar.php b/lang/pl/sidebar.php index 213feb272..3d682ef8a 100644 --- a/lang/pl/sidebar.php +++ b/lang/pl/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Inne', ], 'analytics' => 'Analityka', + 'repurposes' => 'Repurpose', 'onboarding' => 'Pierwsze kroki', 'onboarding_hint' => 'Dokończ konfigurację', 'posts' => [ diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index cb95d308d..7b816cfab 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -123,6 +123,9 @@ ], '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!', 'connected' => 'Conta conectada com sucesso!', 'session_expired' => 'Sessão expirada. Por favor, tente novamente.', 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 new file mode 100644 index 000000000..ce79ef0ee --- /dev/null +++ b/lang/pt-BR/repurposes.php @@ -0,0 +1,187 @@ + 'Repost', + 'description' => 'Reposte automaticamente nas suas outras redes o que você publica fora do TryPost.', + 'new' => 'Novo repost', + + 'flow' => [ + 'no_source' => 'Sem conta de origem', + 'no_destinations' => 'Nenhum destino ainda', + ], + + 'publish_mode' => [ + + 'title' => 'Publicação', + + 'description' => 'O que acontece quando uma publicação nova aparece.', + + ], + + 'publish_modes' => [ + + 'publish' => 'Publicar automaticamente', + + 'publish_hint' => 'Cada publicação nova é agendada assim que é encontrada.', + + 'draft' => 'Criar como rascunho', + + 'draft_hint' => 'Cada publicação nova vira um rascunho aqui para você revisar e publicar.', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => 'Vídeos', + 'story' => 'Stories', + ], + + 'source' => [ + 'title' => 'Origem', + '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', + ], + + 'summary' => [ + 'sentence' => 'Cada novo :format que você postar no :source é repostado 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' => [ + 'title' => 'Nenhum repost configurado', + 'description' => 'O TryPost acompanha a conta que você escolher e republica cada publicação nova nas redes que você marcar.', + ], + + 'table' => [ + 'flow' => 'Fluxo', + 'status' => 'Status', + 'published' => 'Replicados', + 'last_polled' => 'Última verificação', + ], + + 'status' => [ + 'draft' => 'Rascunho', + 'active' => 'Ativo', + 'paused' => 'Pausado', + 'disabled' => 'Desativado', + ], + + 'create' => [ + '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', + 'source_search' => 'Buscar contas', + 'source_empty' => 'Nenhuma conta encontrada.', + 'source_placeholder' => 'Selecione uma conta', + 'no_accounts' => 'Conecte antes uma conta do Instagram ou do Facebook. Só elas podem ser origem, porque são as únicas redes que permitem baixar o vídeo.', + 'submit' => 'Criar', + 'connect' => 'Conectar uma conta', + ], + + 'show' => [ + 'title' => 'Repost', + 'saving' => 'Salvando...', + 'saved' => 'Salvo', + ], + + 'tabs' => [ + 'configuration' => 'Configuração', + 'activity' => 'Atividade', + 'settings' => 'Ajustes', + ], + + 'destinations' => [ + 'paused_note' => 'Desativadas e ignoradas até você reativá-las: :accounts', + 'title' => 'Destinos', + '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.', + 'publish_as' => 'Publicar como', + ], + + 'status_card' => [ + 'title' => 'Status', + 'activate' => 'Ativar', + 'pause' => 'Pausar', + 'resume' => 'Retomar', + 'disable' => 'Desativar', + 'watermark' => 'Acompanhando desde', + 'last_polled' => 'Última verificação', + '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.', + ], + + 'items' => [ + 'source' => 'Original', + 'published_at' => 'Publicado', + 'status' => 'Status', + 'detail' => 'Detalhe', + 'posts' => 'Replicado em', + 'view_original' => 'Ver original', + 'original_from' => 'original de :date', + 'empty' => [ + 'title' => 'Nada ainda', + 'description' => 'As publicações que essa conta fizer fora do TryPost aparecem aqui.', + ], + 'open_post' => 'Abrir post', + 'statuses' => [ + 'pending' => 'Na fila', + 'processing' => 'Processando', + 'published' => 'Replicado', + 'drafted' => 'Rascunho', + 'skipped' => 'Ignorado', + 'failed' => 'Falhou', + ], + 'reasons' => [ + 'published_via_trypost' => 'Já publicado pelo TryPost', + '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' => 'Nenhum destino estava disponível para publicar', + ], + ], + + 'menu' => [ + + 'label' => 'Mais ações', + + ], + + 'danger' => [ + 'title' => 'Excluir este repost', + 'description' => 'As verificações param na hora. Os posts já criados continuam no seu calendário.', + 'delete' => 'Excluir repost', + ], + + '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 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 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 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/lang/pt-BR/sidebar.php b/lang/pt-BR/sidebar.php index b82d527ef..fdf4f8810 100644 --- a/lang/pt-BR/sidebar.php +++ b/lang/pt-BR/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Outros', ], 'analytics' => 'Analytics', + 'repurposes' => 'Repost', 'onboarding' => 'Primeiros passos', 'onboarding_hint' => 'Complete a configuração', 'posts' => [ diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index 32023ef44..577bc5fdd 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -123,6 +123,9 @@ ], 'flash' => [ + 'activated_resumed_repurposes' => 'Аккаунт включён. Возобновлена :count автоматизация.|Аккаунт включён. Возобновлено автоматизаций: :count.', + 'disconnected_paused_repurposes' => 'Аккаунт отключён. Приостановлена :count автоматизация.|Аккаунт отключён. Приостановлено автоматизаций: :count.', + 'deactivated_paused_repurposes' => 'Аккаунт выключен. Приостановлена :count автоматизация.|Аккаунт выключен. Приостановлено автоматизаций: :count.', 'disconnected' => 'Аккаунт успешно отключён!', 'connected' => 'Аккаунт успешно подключён!', 'session_expired' => 'Сессия истекла. Попробуйте ещё раз.', 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 new file mode 100644 index 000000000..b22e2c351 --- /dev/null +++ b/lang/ru/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'Автоматически публикуйте в других сетях то, что вы выкладываете вне TryPost.', + 'new' => 'Новый repurpose', + + 'flow' => [ + 'no_source' => 'Нет исходного аккаунта', + 'no_destinations' => 'Пока нет назначения', + ], + + 'publish_mode' => [ + + 'title' => 'Публикация', + + 'description' => 'Что происходит, когда появляется новая публикация.', + + ], + + 'publish_modes' => [ + + 'publish' => 'Публиковать автоматически', + + 'publish_hint' => 'Каждая новая публикация планируется сразу после обнаружения.', + + 'draft' => 'Создавать черновик', + + 'draft_hint' => 'Каждая новая публикация станет здесь черновиком для проверки и публикации.', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => 'Видео', + 'story' => 'Stories', + ], + + 'source' => [ + 'title' => 'Источник', + 'description' => 'TryPost следит за этим аккаунтом и ищет новые публикации выбранного ниже формата.', + 'account_label' => 'Аккаунт', + 'watch_label' => 'Отслеживать', + 'needs_reconnect' => 'Требуется переподключение', + ], + + 'summary' => [ + 'sentence' => 'Каждое новое :format, опубликованное в :source, повторяется в :destinations.', + 'no_destinations' => 'Каждое новое :format в :source ждёт назначения.', + 'no_source' => 'У этой автоматизации нет исходного аккаунта. Выберите аккаунт, чтобы запустить её снова.', + ], + + 'empty' => [ + 'title' => 'Repurpose ещё не настроен', + 'description' => 'TryPost следит за выбранным аккаунтом и публикует каждую новую публикацию в отмеченных сетях.', + ], + + 'table' => [ + 'flow' => 'Поток', + 'status' => 'Статус', + 'published' => 'Скопировано', + 'last_polled' => 'Последняя проверка', + ], + + 'status' => [ + 'draft' => 'Черновик', + 'active' => 'Активен', + 'paused' => 'На паузе', + 'disabled' => 'Отключён', + ], + + 'create' => [ + 'title' => 'Новый repurpose', + 'description' => 'Выберите аккаунт, за которым будет следить TryPost. Назначения выбираются на следующем экране.', + 'source_label' => 'Аккаунт-источник', + 'source_placeholder' => 'Выберите аккаунт', + 'source_search' => 'Поиск аккаунтов', + 'source_empty' => 'Аккаунт не найден.', + 'source_placeholder' => 'Выберите аккаунт', + 'no_accounts' => 'Сначала подключите аккаунт Instagram или Facebook. Только они могут быть источником, потому что только эти сети позволяют скачать видео.', + 'submit' => 'Создать', + 'connect' => 'Подключить аккаунт', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => 'Сохранение...', + 'saved' => 'Сохранено', + ], + + 'tabs' => [ + 'configuration' => 'Настройка', + 'activity' => 'Активность', + 'settings' => 'Настройки', + ], + + 'destinations' => [ + 'paused_note' => 'Отключены и пропускаются, пока вы их не включите: :accounts', + 'title' => 'Назначения', + 'description' => 'Выберите аккаунты-получатели. Каждый публикует в выбранном вами формате.', + 'hint' => 'Подпись адаптируется под сеть только тогда, когда превышает её лимит.', + 'none_available' => 'В этом рабочем пространстве пока нет других подключённых аккаунтов.', + 'publish_as' => 'Публиковать как', + ], + + 'status_card' => [ + 'title' => 'Статус', + 'activate' => 'Активировать', + 'pause' => 'Пауза', + 'resume' => 'Возобновить', + 'disable' => 'Отключить', + 'watermark' => 'Отслеживается с', + 'last_polled' => 'Последняя проверка', + 'draft_hint' => 'Выберите хотя бы одно назначение и активируйте. Копируются только публикации, сделанные после активации.', + 'active_hint' => 'TryPost регулярно проверяет этот аккаунт и копирует каждую новую публикацию.', + 'paused_hint' => 'Проверки приостановлены. Возобновление продолжит с места остановки, ничего не потеряется.', + 'disabled_hint' => 'Выключено. Повторная активация начнёт с нуля: опубликованное в это время останется в стороне.', + ], + + 'items' => [ + 'source' => 'Оригинал', + 'published_at' => 'Опубликовано', + 'status' => 'Статус', + 'detail' => 'Детали', + 'posts' => 'Скопировано в', + 'view_original' => 'Открыть оригинал', + 'original_from' => 'оригинал от :date', + 'empty' => [ + 'title' => 'Пока ничего', + 'description' => 'Публикации этого аккаунта вне TryPost появятся здесь.', + ], + 'open_post' => 'Открыть пост', + 'statuses' => [ + 'pending' => 'В очереди', + 'processing' => 'Обработка', + 'published' => 'Скопировано', + 'drafted' => 'Черновик', + 'skipped' => 'Пропущено', + 'failed' => 'Ошибка', + ], + 'reasons' => [ + 'published_via_trypost' => 'Уже опубликовано через TryPost', + 'media_url_missing' => 'Сеть не предоставила файл для скачивания, обычно из-за защищённого авторским правом аудио', + 'download_failed' => 'Не удалось скачать видео', + 'post_creation_failed' => 'Не удалось создать публикации', + 'no_usable_destinations' => 'Не было доступных получателей для публикации', + ], + ], + + 'menu' => [ + + 'label' => 'Другие действия', + + ], + + 'danger' => [ + 'title' => 'Удалить этот repurpose', + 'description' => 'Проверки прекратятся сразу. Уже созданные посты останутся в календаре.', + 'delete' => 'Удалить repurpose', + ], + + 'health' => [ + 'stopped_itself' => 'Остановилась сама — откройте, чтобы узнать почему', + 'source_missing' => 'Репликация приостановлена: у этой автоматизации нет исходного аккаунта. Выберите его и возобновите.', + 'source_unusable' => 'Репликация приостановлена: отслеживаемый аккаунт нужно переподключить.', + 'no_destinations' => 'Репликация приостановлена: нет доступных получателей. Добавьте одного и возобновите.', + 'ready' => 'Проблема устранена. Возобновите автоматизацию, чтобы снова публиковать.', + ], + + 'errors' => [ + 'source_already_used' => 'Этот аккаунт уже используется в другом repurpose. Отредактируйте его.', + 'source_missing' => 'Выберите аккаунт для отслеживания перед запуском этой автоматизации.', + 'source_unusable' => 'Переподключите отслеживаемый аккаунт перед запуском этой автоматизации.', + 'destinations_required' => 'Выберите хотя бы одно назначение перед активацией.', + 'destination_needs_video' => 'Этот формат не принимает видео.', + 'only_paused_resumes' => 'Возобновить можно только приостановленный repurpose.', + 'only_active_pauses' => 'Приостановить можно только активный repurpose.', + 'only_running_disables' => 'Отключить можно только работающий repurpose.', + 'only_idle_activates' => 'Активировать можно только черновик или отключённый repurpose.', + 'destination_unavailable' => 'Этот аккаунт-получатель больше недоступен.', + 'destination_is_source' => 'Это назначение — тот же аккаунт, за которым следит этот repurpose.', + 'source_unavailable' => 'Этот исходный аккаунт больше недоступен.', + 'action_failed' => 'Что-то пошло не так. Проверьте форму и повторите.', + ], +]; diff --git a/lang/ru/sidebar.php b/lang/ru/sidebar.php index 358d1a302..45b5ce970 100644 --- a/lang/ru/sidebar.php +++ b/lang/ru/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Прочее', ], 'analytics' => 'Аналитика', + 'repurposes' => 'Repurpose', 'onboarding' => 'Начало работы', 'onboarding_hint' => 'Завершите настройку', 'posts' => [ diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index dafacd2ff..b56a996de 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -125,6 +125,9 @@ ], '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!', 'connected' => 'Hesap başarıyla bağlandı!', 'session_expired' => 'Oturum süresi doldu. Lütfen tekrar deneyin.', 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 new file mode 100644 index 000000000..0076bc385 --- /dev/null +++ b/lang/tr/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'TryPost dışında paylaştıklarını diğer ağlarında otomatik olarak yeniden yayınla.', + 'new' => 'Yeni repurpose', + + 'flow' => [ + 'no_source' => 'Kaynak hesap yok', + 'no_destinations' => 'Henüz hedef yok', + ], + + 'publish_mode' => [ + + 'title' => 'Yayınlama', + + 'description' => 'Yeni bir gönderi göründüğünde ne olur.', + + ], + + 'publish_modes' => [ + + 'publish' => 'Otomatik yayınla', + + 'publish_hint' => 'Her yeni gönderi bulunduğu anda planlanır.', + + 'draft' => 'Taslak olarak oluştur', + + 'draft_hint' => 'Her yeni gönderi, gözden geçirip yayınlaman için burada taslak olur.', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => 'Videolar', + 'story' => 'Hikayeler', + ], + + 'source' => [ + 'title' => 'Kaynak', + '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ı', + ], + + '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' => [ + 'title' => 'Henüz repurpose kurulmadı', + 'description' => 'TryPost seçtiğiniz hesabı izler ve her yeni gönderiyi işaretlediğiniz ağlarda yeniden paylaşır.', + ], + + 'table' => [ + 'flow' => 'Akış', + 'status' => 'Durum', + 'published' => 'Kopyalanan', + 'last_polled' => 'Son kontrol', + ], + + 'status' => [ + 'draft' => 'Taslak', + 'active' => 'Etkin', + 'paused' => 'Duraklatıldı', + 'disabled' => 'Devre dışı', + ], + + 'create' => [ + 'title' => 'Yeni repurpose', + 'description' => 'TryPost\'un izlemesi gereken hesabı seç. Hedefleri bir sonraki ekranda seçeceksin.', + 'source_label' => 'Kaynak hesap', + 'source_placeholder' => 'Bir hesap seç', + 'source_search' => 'Hesap ara', + 'source_empty' => 'Hesap bulunamadı.', + 'source_placeholder' => 'Bir hesap seç', + 'no_accounts' => 'Önce bir Instagram veya Facebook hesabı bağla. Yalnızca bunlar kaynak olabilir, çünkü videoyu indirmemize izin veren tek ağlar bunlar.', + 'submit' => 'Oluştur', + 'connect' => 'Hesap bağla', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => 'Kaydediliyor...', + 'saved' => 'Kaydedildi', + ], + + 'tabs' => [ + 'configuration' => 'Yapılandırma', + 'activity' => 'Etkinlik', + 'settings' => 'Ayarlar', + ], + + 'destinations' => [ + 'paused_note' => 'Kapalı ve yeniden açana kadar atlanıyor: :accounts', + 'title' => 'Hedefler', + '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.', + 'publish_as' => 'Şu olarak paylaş', + ], + + 'status_card' => [ + 'title' => 'Durum', + 'activate' => 'Etkinleştir', + 'pause' => 'Duraklat', + 'resume' => 'Sürdür', + '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 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.', + ], + + 'items' => [ + 'source' => 'Orijinal', + 'published_at' => 'Paylaşıldı', + 'status' => 'Durum', + 'detail' => 'Ayrıntı', + 'posts' => 'Kopyalandığı yer', + 'view_original' => 'Orijinali gör', + 'original_from' => ':date tarihli özgün gönderi', + 'empty' => [ + 'title' => 'Henüz bir şey yok', + 'description' => 'Bu hesabın TryPost dışında paylaştığı gönderiler burada görünür.', + ], + 'open_post' => 'Gönderiyi aç', + 'statuses' => [ + 'pending' => 'Sırada', + 'processing' => 'İşleniyor', + 'published' => 'Kopyalandı', + 'drafted' => 'Taslak', + 'skipped' => 'Atlandı', + 'failed' => 'Başarısız', + ], + 'reasons' => [ + 'published_via_trypost' => 'Zaten TryPost ile yayınlandı', + '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' => 'Yayınlanacak uygun bir hedef yoktu', + ], + ], + + 'menu' => [ + + 'label' => 'Diğer işlemler', + + ], + + 'danger' => [ + 'title' => 'Bu repurpose\'u sil', + 'description' => 'Kontroller hemen durur. Oluşturulmuş gönderiler takviminde kalır.', + '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.', + 'source_unusable' => 'Bu otomasyonu başlatmadan önce izlenen hesabı yeniden bağlayın.', + 'destinations_required' => 'Etkinleştirmeden önce en az bir hedef seç.', + 'destination_needs_video' => 'Bu format video taşıyamaz.', + 'only_paused_resumes' => 'Yalnızca duraklatılmış bir repurpose sürdürülebilir.', + 'only_active_pauses' => 'Yalnızca etkin bir repurpose duraklatılabilir.', + 'only_running_disables' => 'Yalnızca çalışan bir repurpose kapatılabilir.', + 'only_idle_activates' => 'Yalnızca taslak veya kapatılmış bir repurpose etkinleştirilebilir.', + 'destination_unavailable' => 'O hedef hesap artık kullanılabilir değil.', + 'destination_is_source' => 'Bu hedef, bu repurpose\'un izlediği hesabın kendisi.', + 'source_unavailable' => 'Bu kaynak hesap artık kullanılamıyor.', + 'action_failed' => 'Bir şeyler ters gitti. Formu kontrol edip tekrar dene.', + ], +]; diff --git a/lang/tr/sidebar.php b/lang/tr/sidebar.php index 4142f3c21..6769e4eb7 100644 --- a/lang/tr/sidebar.php +++ b/lang/tr/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Diğerleri', ], 'analytics' => 'Analitik', + 'repurposes' => 'Repurpose', 'onboarding' => 'Başlarken', 'onboarding_hint' => 'Kurulumu bitir', 'posts' => [ diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index 20b510076..f4e05888a 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -123,6 +123,9 @@ ], 'flash' => [ + 'activated_resumed_repurposes' => 'Обліковий запис увімкнено. Відновлено :count автоматизацію.|Обліковий запис увімкнено. Відновлено автоматизацій: :count.', + 'disconnected_paused_repurposes' => 'Обліковий запис відключено. Призупинено :count автоматизацію.|Обліковий запис відключено. Призупинено автоматизацій: :count.', + 'deactivated_paused_repurposes' => 'Обліковий запис вимкнено. Призупинено :count автоматизацію.|Обліковий запис вимкнено. Призупинено автоматизацій: :count.', 'disconnected' => 'Акаунт успішно від’єднано!', 'connected' => 'Акаунт успішно підключено!', 'session_expired' => 'Сесію завершено. Спробуйте ще раз.', 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 new file mode 100644 index 000000000..f7070702d --- /dev/null +++ b/lang/uk/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => 'Автоматично публікуйте в інших мережах те, що ви викладаєте поза TryPost.', + 'new' => 'Новий repurpose', + + 'flow' => [ + 'no_source' => 'Немає вихідного облікового запису', + 'no_destinations' => 'Ще немає призначення', + ], + + 'publish_mode' => [ + + 'title' => 'Публікація', + + 'description' => 'Що відбувається, коли з\'являється новий допис.', + + ], + + 'publish_modes' => [ + + 'publish' => 'Публікувати автоматично', + + 'publish_hint' => 'Кожен новий допис планується одразу після виявлення.', + + 'draft' => 'Створювати чернетку', + + 'draft_hint' => 'Кожен новий допис стає тут чернеткою для перевірки та публікації.', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => 'Відео', + 'story' => 'Stories', + ], + + 'source' => [ + 'title' => 'Джерело', + 'description' => 'TryPost стежить за цим акаунтом і шукає нові дописи обраного нижче формату.', + 'account_label' => 'Обліковий запис', + 'watch_label' => 'Відстежувати', + 'needs_reconnect' => 'Потрібне перепідключення', + ], + + 'summary' => [ + 'sentence' => 'Кожне нове :format, опубліковане в :source, повторюється в :destinations.', + 'no_destinations' => 'Кожне нове :format у :source чекає на призначення.', + 'no_source' => 'У цієї автоматизації немає вихідного облікового запису. Виберіть його, щоб запустити знову.', + ], + + 'empty' => [ + 'title' => 'Repurpose ще не налаштовано', + 'description' => 'TryPost стежить за вибраним обліковим записом і публікує кожен новий допис у позначених мережах.', + ], + + 'table' => [ + 'flow' => 'Потік', + 'status' => 'Статус', + 'published' => 'Скопійовано', + 'last_polled' => 'Остання перевірка', + ], + + 'status' => [ + 'draft' => 'Чернетка', + 'active' => 'Активний', + 'paused' => 'Призупинено', + 'disabled' => 'Вимкнено', + ], + + 'create' => [ + 'title' => 'Новий repurpose', + 'description' => 'Оберіть акаунт, за яким стежитиме TryPost. Призначення обираються на наступному екрані.', + 'source_label' => 'Акаунт-джерело', + 'source_placeholder' => 'Виберіть обліковий запис', + 'source_search' => 'Пошук облікових записів', + 'source_empty' => 'Обліковий запис не знайдено.', + 'source_placeholder' => 'Оберіть акаунт', + 'no_accounts' => 'Спершу підключіть акаунт Instagram або Facebook. Лише вони можуть бути джерелом, бо тільки ці мережі дозволяють завантажити відео.', + 'submit' => 'Створити', + 'connect' => 'Підключити акаунт', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => 'Збереження...', + 'saved' => 'Збережено', + ], + + 'tabs' => [ + 'configuration' => 'Налаштування', + 'activity' => 'Активність', + 'settings' => 'Налаштування', + ], + + 'destinations' => [ + 'paused_note' => 'Вимкнено та пропускаються, доки ви їх не увімкнете: :accounts', + 'title' => 'Призначення', + 'description' => 'Оберіть акаунти-отримувачі. Кожен публікує в обраному вами форматі.', + 'hint' => 'Підпис адаптується під мережу лише тоді, коли перевищує її ліміт.', + 'none_available' => 'У цьому робочому просторі поки немає інших підключених акаунтів.', + 'publish_as' => 'Публікувати як', + ], + + 'status_card' => [ + 'title' => 'Статус', + 'activate' => 'Активувати', + 'pause' => 'Призупинити', + 'resume' => 'Відновити', + 'disable' => 'Вимкнути', + 'watermark' => 'Відстежується з', + 'last_polled' => 'Остання перевірка', + 'draft_hint' => 'Оберіть щонайменше одне призначення та активуйте. Копіюються лише дописи, опубліковані після активації.', + 'active_hint' => 'TryPost регулярно перевіряє цей акаунт і копіює кожен новий допис.', + 'paused_hint' => 'Перевірки призупинено. Відновлення продовжить з місця зупинки, нічого не втратиться.', + 'disabled_hint' => 'Вимкнено. Повторна активація почне з нуля: опубліковане за цей час залишиться осторонь.', + ], + + 'items' => [ + 'source' => 'Оригінал', + 'published_at' => 'Опубліковано', + 'status' => 'Статус', + 'detail' => 'Деталі', + 'posts' => 'Скопійовано в', + 'view_original' => 'Відкрити оригінал', + 'original_from' => 'оригінал від :date', + 'empty' => [ + 'title' => 'Поки нічого', + 'description' => 'Дописи цього облікового запису поза TryPost з\'являться тут.', + ], + 'open_post' => 'Відкрити допис', + 'statuses' => [ + 'pending' => 'У черзі', + 'processing' => 'Обробка', + 'published' => 'Скопійовано', + 'drafted' => 'Чернетка', + 'skipped' => 'Пропущено', + 'failed' => 'Помилка', + ], + 'reasons' => [ + 'published_via_trypost' => 'Уже опубліковано через TryPost', + 'media_url_missing' => 'Мережа не надала файл для завантаження, зазвичай через захищене авторським правом аудіо', + 'download_failed' => 'Не вдалося завантажити відео', + 'post_creation_failed' => 'Не вдалося створити публікації', + 'no_usable_destinations' => 'Не було доступних призначень для публікації', + ], + ], + + 'menu' => [ + + 'label' => 'Інші дії', + + ], + + 'danger' => [ + 'title' => 'Видалити цей repurpose', + 'description' => 'Перевірки припиняться одразу. Уже створені дописи залишаться в календарі.', + 'delete' => 'Видалити repurpose', + ], + + 'health' => [ + 'stopped_itself' => 'Зупинилася сама — відкрийте, щоб дізнатися чому', + 'source_missing' => 'Реплікацію призупинено: у цієї автоматизації немає вихідного облікового запису. Виберіть його та відновіть.', + 'source_unusable' => 'Реплікацію призупинено: відстежуваний обліковий запис потрібно перепідключити.', + 'no_destinations' => 'Реплікацію призупинено: немає доступних призначень. Додайте одне та відновіть.', + 'ready' => 'Проблему усунено. Відновіть цю автоматизацію, щоб знову публікувати.', + ], + + 'errors' => [ + 'source_already_used' => 'Цей акаунт уже живить інший repurpose. Відредагуйте його.', + 'source_missing' => 'Виберіть обліковий запис для відстеження перед запуском цієї автоматизації.', + 'source_unusable' => 'Перепідключіть відстежуваний обліковий запис перед запуском цієї автоматизації.', + 'destinations_required' => 'Оберіть щонайменше одне призначення перед активацією.', + 'destination_needs_video' => 'Цей формат не приймає відео.', + 'only_paused_resumes' => 'Відновити можна лише призупинений repurpose.', + 'only_active_pauses' => 'Призупинити можна лише активний repurpose.', + 'only_running_disables' => 'Вимкнути можна лише той repurpose, що працює.', + 'only_idle_activates' => 'Активувати можна лише чернетку або вимкнений repurpose.', + 'destination_unavailable' => 'Цей акаунт-отримувач більше недоступний.', + 'destination_is_source' => 'Це призначення — той самий обліковий запис, за яким стежить цей repurpose.', + 'source_unavailable' => 'Цей обліковий запис-джерело більше недоступний.', + 'action_failed' => 'Щось пішло не так. Перевірте форму та спробуйте ще раз.', + ], +]; diff --git a/lang/uk/sidebar.php b/lang/uk/sidebar.php index 6a91ddcd4..f7ded40b1 100644 --- a/lang/uk/sidebar.php +++ b/lang/uk/sidebar.php @@ -26,6 +26,7 @@ 'others' => 'Інше', ], 'analytics' => 'Аналітика', + 'repurposes' => 'Repurpose', 'onboarding' => 'Початок роботи', 'onboarding_hint' => 'Завершіть налаштування', 'posts' => [ diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index bae4ccdc5..bd9a964be 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -123,6 +123,9 @@ ], 'flash' => [ + 'activated_resumed_repurposes' => '账号已开启。已恢复 :count 个自动化。|账号已开启。已恢复 :count 个自动化。', + 'disconnected_paused_repurposes' => '账号已断开连接。已暂停 :count 个自动化。|账号已断开连接。已暂停 :count 个自动化。', + 'deactivated_paused_repurposes' => '账号已关闭。已暂停 :count 个自动化。|账号已关闭。已暂停 :count 个自动化。', 'disconnected' => '账号已成功断开连接!', 'connected' => '账号连接成功!', 'session_expired' => '会话已过期,请重试。', 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 new file mode 100644 index 000000000..3d0f1f362 --- /dev/null +++ b/lang/zh/repurposes.php @@ -0,0 +1,187 @@ + 'Repurpose', + 'description' => '把你在 TryPost 之外发布的内容,自动同步到其他平台。', + 'new' => '新建 Repurpose', + + 'flow' => [ + 'no_source' => '没有来源账号', + 'no_destinations' => '还没有目标', + ], + + 'publish_mode' => [ + + 'title' => '发布', + + 'description' => '发现新内容时会发生什么。', + + ], + + 'publish_modes' => [ + + 'publish' => '自动发布', + + 'publish_hint' => '每条新内容一被发现就会排入发布计划。', + + 'draft' => '创建为草稿', + + 'draft_hint' => '每条新内容都会在这里生成草稿,供你检查后发布。', + + ], + + 'formats' => [ + 'reel' => 'Reels', + 'video' => '视频', + 'story' => '快拍', + ], + + 'source' => [ + 'title' => '来源', + 'description' => 'TryPost 会盯着这个账号,寻找下面所选格式的新内容。', + 'account_label' => '账号', + 'watch_label' => '监控格式', + 'needs_reconnect' => '需要重新连接', + ], + + 'summary' => [ + 'sentence' => '你每次在 :source 发布新的 :format,都会同步到 :destinations。', + 'no_destinations' => '你在 :source 发布的每条新 :format 还在等待目标。', + 'no_source' => '此自动化没有来源账号。请选择一个以重新启动。', + ], + + 'empty' => [ + 'title' => '还没有设置 Repurpose', + 'description' => 'TryPost 会监控你选择的账号,并将每条新内容重新发布到你勾选的网络。', + ], + + 'table' => [ + 'flow' => '流程', + 'status' => '状态', + 'published' => '已同步', + 'last_polled' => '上次检查', + ], + + 'status' => [ + 'draft' => '草稿', + 'active' => '启用中', + 'paused' => '已暂停', + 'disabled' => '已停用', + ], + + 'create' => [ + 'title' => '新建 Repurpose', + 'description' => '选择 TryPost 要盯着的账号。目标平台在下一屏选择。', + 'source_label' => '来源账号', + 'source_placeholder' => '选择一个账号', + 'source_search' => '搜索账号', + 'source_empty' => '未找到账号。', + 'source_placeholder' => '选择账号', + 'no_accounts' => '请先连接 Instagram 或 Facebook 账号。只有它们能作为来源,因为只有这两个平台允许我们下载视频。', + 'submit' => '创建', + 'connect' => '连接账号', + ], + + 'show' => [ + 'title' => 'Repurpose', + 'saving' => '保存中…', + 'saved' => '已保存', + ], + + 'tabs' => [ + 'configuration' => '配置', + 'activity' => '动态', + 'settings' => '设置', + ], + + 'destinations' => [ + 'paused_note' => '已关闭,重新开启前将被跳过::accounts', + 'title' => '目标', + 'description' => '选择接收的账号。每个账号按你指定的格式发布。', + 'hint' => '只有当文案超出该平台上限时,才会按平台调整。', + 'none_available' => '这个工作区还没有连接其他账号。', + 'publish_as' => '发布为', + ], + + 'status_card' => [ + 'title' => '状态', + 'activate' => '启用', + 'pause' => '暂停', + 'resume' => '继续', + 'disable' => '停用', + 'watermark' => '开始监控于', + 'last_polled' => '上次检查', + 'draft_hint' => '至少选一个目标再启用。只有启用之后发布的内容才会被同步。', + 'active_hint' => 'TryPost 会定期检查这个账号,并同步每条新内容。', + 'paused_hint' => '检查已暂停。继续后会从停下的地方接着走,期间发布的内容不会丢失。', + 'disabled_hint' => '已关闭。再次启用会重新开始:关闭期间发布的内容不会被同步。', + ], + + 'items' => [ + 'source' => '原视频', + 'published_at' => '发布于', + 'status' => '状态', + 'detail' => '详情', + 'posts' => '已同步到', + 'view_original' => '查看原视频', + 'original_from' => '原帖发布于 :date', + 'empty' => [ + 'title' => '暂无内容', + 'description' => '该账号在 TryPost 之外发布的内容会显示在这里。', + ], + 'open_post' => '打开帖子', + 'statuses' => [ + 'pending' => '排队中', + 'processing' => '处理中', + 'published' => '已同步', + 'drafted' => '草稿', + 'skipped' => '已跳过', + 'failed' => '失败', + ], + 'reasons' => [ + 'published_via_trypost' => '已通过 TryPost 发布', + 'media_url_missing' => '平台没有提供可下载的文件,通常是因为音频有版权', + 'download_failed' => '视频下载失败', + 'post_creation_failed' => '无法创建帖子', + 'no_usable_destinations' => '没有可发布的目标账号', + ], + ], + + 'menu' => [ + + 'label' => '更多操作', + + ], + + 'danger' => [ + 'title' => '删除这个 Repurpose', + 'description' => '检查会立即停止。已创建的帖子会保留在日历中。', + 'delete' => '删除 Repurpose', + ], + + 'health' => [ + 'stopped_itself' => '已自动停止 — 打开查看原因', + 'source_missing' => '复制已暂停:此自动化没有来源账号。请选择一个后再继续。', + 'source_unusable' => '复制已暂停:此自动化监控的账号需要重新连接。', + 'no_destinations' => '复制已暂停:没有可用的目标账号。请添加一个后再继续。', + 'ready' => '问题已解决。继续此自动化即可重新开始复制。', + ], + + 'errors' => [ + 'source_already_used' => '这个账号已经用于另一个 Repurpose,请去编辑那一个。', + 'source_missing' => '开始此自动化之前,请选择要监控的账号。', + 'source_unusable' => '开始此自动化之前,请重新连接此自动化监控的账号。', + 'destinations_required' => '启用前请至少选择一个目标。', + 'destination_needs_video' => '该格式不支持视频。', + 'only_paused_resumes' => '只有已暂停的 Repurpose 才能继续。', + 'only_active_pauses' => '只有正在运行的转发规则才能暂停。', + 'only_running_disables' => '只有正在运行的转发规则才能停用。', + 'only_idle_activates' => '只有草稿或已停用的转发规则才能启用。', + 'destination_unavailable' => '该目标账号已不可用。', + 'destination_is_source' => '该目标就是此转发规则正在监视的账号。', + 'source_unavailable' => '该来源账号已不可用。', + 'action_failed' => '出了点问题。请检查表单后重试。', + ], +]; diff --git a/lang/zh/sidebar.php b/lang/zh/sidebar.php index 0cfb68160..56d3a1808 100644 --- a/lang/zh/sidebar.php +++ b/lang/zh/sidebar.php @@ -26,6 +26,7 @@ 'others' => '其他', ], 'analytics' => '分析', + 'repurposes' => 'Repurpose', 'onboarding' => '开始使用', 'onboarding_hint' => '完成设置', 'posts' => [ diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index cdef840c4..f447b83e8 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -16,6 +16,7 @@ import { IconPencil, IconPhoto, IconPlugConnected, + IconRepeat, IconSelector, IconTag, IconWebhook, @@ -55,6 +56,7 @@ import { index as assets } from '@/routes/app/assets'; import { portal } from '@/routes/app/billing'; import { index as labels } from '@/routes/app/labels'; import { index as mcp } from '@/routes/app/mcp'; +import { index as repurposes } from '@/routes/app/repurposes'; import { index as signatures } from '@/routes/app/signatures'; import { index as webhooks } from '@/routes/app/webhooks'; import type { NavItem, User } from '@/types'; @@ -79,6 +81,7 @@ const subscriptionPastDue = computed(() => const { canCreatePost, + canManageRepurposes, canManageAccounts, canManageWebhooks, canCreateWorkspace, @@ -96,6 +99,16 @@ const mainNavItems = computed(() => [ href: analytics.url(), icon: IconChartBar, }, + ...(canManageRepurposes.value + ? [ + { + title: trans('sidebar.repurposes'), + href: repurposes.url(), + icon: IconRepeat, + badge: trans('common.beta'), + }, + ] + : []), ]); const postsNavItems = computed(() => [ 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/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/components/SearchableSelect.vue b/resources/js/components/SearchableSelect.vue index fdad0bb27..e79f41d7f 100644 --- a/resources/js/components/SearchableSelect.vue +++ b/resources/js/components/SearchableSelect.vue @@ -1,4 +1,4 @@ - + + diff --git a/resources/js/components/repurpose/PublishModeCard.vue b/resources/js/components/repurpose/PublishModeCard.vue new file mode 100644 index 000000000..2e015d8c0 --- /dev/null +++ b/resources/js/components/repurpose/PublishModeCard.vue @@ -0,0 +1,42 @@ + + + diff --git a/resources/js/components/repurpose/RepurposeFlow.vue b/resources/js/components/repurpose/RepurposeFlow.vue new file mode 100644 index 000000000..41df0ca51 --- /dev/null +++ b/resources/js/components/repurpose/RepurposeFlow.vue @@ -0,0 +1,74 @@ + + + diff --git a/resources/js/components/repurpose/RepurposeHealthBanner.vue b/resources/js/components/repurpose/RepurposeHealthBanner.vue new file mode 100644 index 000000000..178d900e0 --- /dev/null +++ b/resources/js/components/repurpose/RepurposeHealthBanner.vue @@ -0,0 +1,57 @@ + + + diff --git a/resources/js/components/repurpose/RepurposeItemList.vue b/resources/js/components/repurpose/RepurposeItemList.vue new file mode 100644 index 000000000..080c171ec --- /dev/null +++ b/resources/js/components/repurpose/RepurposeItemList.vue @@ -0,0 +1,145 @@ + + + diff --git a/resources/js/components/repurpose/RepurposeLifecycle.vue b/resources/js/components/repurpose/RepurposeLifecycle.vue new file mode 100644 index 000000000..f93e3dd76 --- /dev/null +++ b/resources/js/components/repurpose/RepurposeLifecycle.vue @@ -0,0 +1,115 @@ + + + diff --git a/resources/js/components/repurpose/RepurposeSummary.vue b/resources/js/components/repurpose/RepurposeSummary.vue new file mode 100644 index 000000000..18a7256cf --- /dev/null +++ b/resources/js/components/repurpose/RepurposeSummary.vue @@ -0,0 +1,57 @@ + + + diff --git a/resources/js/components/repurpose/SourceFormatCard.vue b/resources/js/components/repurpose/SourceFormatCard.vue new file mode 100644 index 000000000..55258815e --- /dev/null +++ b/resources/js/components/repurpose/SourceFormatCard.vue @@ -0,0 +1,120 @@ + + + diff --git a/resources/js/components/settings/WorkspaceTab.vue b/resources/js/components/settings/WorkspaceTab.vue index 100fdc5c1..f1219de3c 100644 --- a/resources/js/components/settings/WorkspaceTab.vue +++ b/resources/js/components/settings/WorkspaceTab.vue @@ -2,10 +2,10 @@ import { Form } from '@inertiajs/vue3'; import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceController'; -import DeleteWorkspace from '@/components/settings/DeleteWorkspace.vue'; import HeadingSmall from '@/components/HeadingSmall.vue'; import InputError from '@/components/InputError.vue'; import PhotoUpload from '@/components/PhotoUpload.vue'; +import DeleteWorkspace from '@/components/settings/DeleteWorkspace.vue'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; diff --git a/resources/js/composables/useWorkspaceRole.ts b/resources/js/composables/useWorkspaceRole.ts index 84b766e7d..ef86de1e5 100644 --- a/resources/js/composables/useWorkspaceRole.ts +++ b/resources/js/composables/useWorkspaceRole.ts @@ -29,6 +29,7 @@ export const useWorkspaceRole = () => { isAdminOrAbove, isMemberOrAbove, canCreatePost: isMemberOrAbove, + canManageRepurposes: isMemberOrAbove, canManageAccounts: isAdminOrAbove, canManageWebhooks: isAdminOrAbove, canManageTeam: isAdminOrAbove, diff --git a/resources/js/pages/repurposes/Index.vue b/resources/js/pages/repurposes/Index.vue new file mode 100644 index 000000000..2f2550378 --- /dev/null +++ b/resources/js/pages/repurposes/Index.vue @@ -0,0 +1,138 @@ + + + diff --git a/resources/js/pages/repurposes/Show.vue b/resources/js/pages/repurposes/Show.vue new file mode 100644 index 000000000..d708c0135 --- /dev/null +++ b/resources/js/pages/repurposes/Show.vue @@ -0,0 +1,393 @@ + + + diff --git a/resources/js/types/channel.ts b/resources/js/types/channel.ts index 63c06700a..2ddcda83a 100644 --- a/resources/js/types/channel.ts +++ b/resources/js/types/channel.ts @@ -7,6 +7,8 @@ export interface ChannelAccount { username: string; display_label: string; avatar_url: string | null; + is_active?: boolean; + status?: string; } export interface ChannelTikTokCreatorInfo { @@ -20,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/index.d.ts b/resources/js/types/index.d.ts index ae3b1bac1..ea013048a 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -59,6 +59,7 @@ export interface NavItem { activePattern?: string; exact?: boolean; excludeActive?: string[]; + badge?: string; } export interface OnboardingProgress { diff --git a/resources/js/types/repurpose-status.ts b/resources/js/types/repurpose-status.ts new file mode 100644 index 000000000..08f30690e --- /dev/null +++ b/resources/js/types/repurpose-status.ts @@ -0,0 +1,59 @@ +export const RepurposeStatus = { + Draft: 'draft', + Active: 'active', + Paused: 'paused', + Disabled: 'disabled', +} as const; + +export type RepurposeStatusValue = (typeof RepurposeStatus)[keyof typeof RepurposeStatus]; + +export const PauseReason = { + SourceRemoved: 'source_removed', + SourceUnavailable: 'source_unavailable', + NoDestinations: 'no_destinations', +} as const; + +export type PauseReasonValue = (typeof PauseReason)[keyof typeof PauseReason]; + +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', + Published: 'published', + Drafted: 'drafted', + Skipped: 'skipped', + Failed: 'failed', +} as const; + +export type RepurposeItemStatusValue = (typeof RepurposeItemStatus)[keyof typeof RepurposeItemStatus]; + +type BadgeVariant = 'default' | 'secondary' | 'warning' | 'destructive' | 'outline'; + +const statusVariants = { + [RepurposeStatus.Draft]: 'outline', + [RepurposeStatus.Active]: 'default', + [RepurposeStatus.Paused]: 'warning', + [RepurposeStatus.Disabled]: 'secondary', +} as const satisfies Record; + +const itemStatusVariants = { + [RepurposeItemStatus.Pending]: 'outline', + [RepurposeItemStatus.Processing]: 'outline', + [RepurposeItemStatus.Published]: 'default', + [RepurposeItemStatus.Drafted]: 'outline', + [RepurposeItemStatus.Skipped]: 'secondary', + [RepurposeItemStatus.Failed]: 'destructive', +} as const satisfies Record; + +export const repurposeStatusVariant = (status: RepurposeStatusValue): BadgeVariant => statusVariants[status]; + +export const repurposeItemStatusVariant = (status: RepurposeItemStatusValue): BadgeVariant => + itemStatusVariants[status]; diff --git a/resources/js/types/repurpose.ts b/resources/js/types/repurpose.ts new file mode 100644 index 000000000..addc00877 --- /dev/null +++ b/resources/js/types/repurpose.ts @@ -0,0 +1,72 @@ +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'; + +export type RepurposePublishMode = 'publish' | 'draft'; + +export interface PublishModeOption { + value: RepurposePublishMode; + label: string; + description: string; +} + +export interface SourceFormatOption { + value: RepurposeSourceFormat; + label: string; +} + +export interface FlowNode { + platform: string; + label?: string | null; + username?: string | null; + format?: string | null; +} + +export interface RepurposeDestination { + social_account_id: string; + content_type: string; + meta: Record; +} + +export interface Repurpose { + id: string; + source_social_account_id: string | null; + source_format: RepurposeSourceFormat; + publish_mode: RepurposePublishMode; + source_account?: ChannelAccount | null; + destinations: RepurposeDestination[]; + status: RepurposeStatusValue; + paused_reason: PauseReasonValue | null; + activated_at: string | null; + last_polled_at: string | null; + next_poll_at: string | null; + last_error: string | null; + published_items_count?: number; + created_at: string; + updated_at: string; +} + +export interface RepurposeItemPlatform { + platform: string; + status: PostPlatformStatusValue | null; +} + +export interface RepurposeItemPost { + id: string; + platforms: RepurposeItemPlatform[]; +} + +export interface RepurposeItem { + id: string; + source_media_id: string; + source_permalink: string | null; + source_created_at: string | null; + status: RepurposeItemStatusValue; + reason: string | null; + error: string | null; + posts?: RepurposeItemPost[]; + created_at: string; +} + diff --git a/resources/views/prompts/post_content/shortener.blade.php b/resources/views/prompts/post_content/shortener.blade.php new file mode 100644 index 000000000..d53ddca39 --- /dev/null +++ b/resources/views/prompts/post_content/shortener.blade.php @@ -0,0 +1,29 @@ +You are a social media copy editor. Your job: shorten a caption so it fits a hard character limit on {{ $platform_label ?? 'the target platform' }}, without losing what makes it work. + +@if(!empty($brand_name)) +You are editing content for the brand "{{ $brand_name }}". +@endif +@if(!empty($brand_voice_traits)) +Brand voice — keep this tone, vocabulary, and rhythm: +@include('prompts.post_content._voice', ['brand_voice_traits' => $brand_voice_traits]) +@endif + +Output language: the same language as the caption you receive. This is a trim, so never translate it. + +## Length + +- Hard cap, must NEVER be exceeded: {{ $limit }} characters, counting spaces, line breaks, emoji and hashtags. +- Aim for about {{ $target }} characters. Landing under the cap matters more than using all of it. +- Count before replying. A result over the cap is a failed response. + +## Rules + +- Return ONLY the shortened caption. No preamble, no quotes around it, no explanation. +- Keep the hook: the first sentence is what stops the scroll, so protect it. +- Keep the call to action if there is one. +- Keep at most the two most relevant hashtags; drop the rest before you cut real words. +- Keep the line breaks that separate ideas. Do not flatten the caption into one paragraph. +- Drop redundancy, filler and repeated ideas before you drop information. +- Keep the author's voice and tone. This is a trim, not a rewrite. +- Never invent facts, offers, dates or numbers that are not in the original. +- Never use em dashes or en dashes (— –). Use a comma, a colon, parentheses, or a new sentence. The result must contain zero — and – characters. diff --git a/routes/api.php b/routes/api.php index 138fdcbc2..698dd2108 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Api\LabelController; use App\Http\Controllers\Api\PlatformController; use App\Http\Controllers\Api\PostController; +use App\Http\Controllers\Api\RepurposeController; use App\Http\Controllers\Api\SignatureController; use App\Http\Controllers\Api\SocialAccountController; use App\Http\Controllers\Api\UploadController; @@ -64,6 +65,19 @@ ->middleware('throttle:60,1') ->name('api.social-accounts.channels'); + // Repurpose + 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'); + Route::put('/repurposes/{repurpose}', [RepurposeController::class, 'update'])->name('api.repurposes.update'); + Route::get('/repurposes/{repurpose}/items', [RepurposeController::class, 'items'])->name('api.repurposes.items'); + Route::post('/repurposes/{repurpose}/activate', [RepurposeController::class, 'activate'])->name('api.repurposes.activate'); + Route::post('/repurposes/{repurpose}/pause', [RepurposeController::class, 'pause'])->name('api.repurposes.pause'); + Route::post('/repurposes/{repurpose}/resume', [RepurposeController::class, 'resume'])->name('api.repurposes.resume'); + Route::post('/repurposes/{repurpose}/disable', [RepurposeController::class, 'disable'])->name('api.repurposes.disable'); + Route::delete('/repurposes/{repurpose}', [RepurposeController::class, 'destroy'])->name('api.repurposes.destroy'); + // Webhooks Route::get('/webhooks', [WebhookController::class, 'index'])->name('api.webhooks.index'); Route::post('/webhooks', [WebhookController::class, 'store'])->name('api.webhooks.store'); diff --git a/routes/app.php b/routes/app.php index f44d887e1..eb8c92b13 100644 --- a/routes/app.php +++ b/routes/app.php @@ -19,6 +19,7 @@ use App\Http\Controllers\App\PostCommentController; use App\Http\Controllers\App\PostController; use App\Http\Controllers\App\PresenceController; +use App\Http\Controllers\App\RepurposeController; use App\Http\Controllers\App\Settings\AccountController; use App\Http\Controllers\App\Settings\AuthenticationController; use App\Http\Controllers\App\Settings\NotificationPreferenceController; @@ -257,6 +258,17 @@ Route::get('settings/workspace/mcp', [McpSettingsController::class, 'index'])->name('app.mcp.index'); Route::delete('settings/workspace/mcp/{client}', [McpSettingsController::class, 'disconnect'])->name('app.mcp.disconnect'); + // Repurpose + Route::get('repurposes', [RepurposeController::class, 'index'])->name('app.repurposes.index'); + Route::post('repurposes', [RepurposeController::class, 'store'])->name('app.repurposes.store'); + Route::get('repurposes/{repurpose}', [RepurposeController::class, 'show'])->name('app.repurposes.show'); + Route::put('repurposes/{repurpose}', [RepurposeController::class, 'update'])->name('app.repurposes.update'); + Route::post('repurposes/{repurpose}/activate', [RepurposeController::class, 'activate'])->name('app.repurposes.activate'); + Route::post('repurposes/{repurpose}/pause', [RepurposeController::class, 'pause'])->name('app.repurposes.pause'); + Route::post('repurposes/{repurpose}/resume', [RepurposeController::class, 'resume'])->name('app.repurposes.resume'); + Route::post('repurposes/{repurpose}/disable', [RepurposeController::class, 'disable'])->name('app.repurposes.disable'); + Route::delete('repurposes/{repurpose}', [RepurposeController::class, 'destroy'])->name('app.repurposes.destroy'); + // Webhooks Route::get('webhooks', [WebhookController::class, 'index'])->name('app.webhooks.index'); Route::post('webhooks', [WebhookController::class, 'store'])->name('app.webhooks.store'); diff --git a/routes/console.php b/routes/console.php index d74db37f4..f955621a3 100644 --- a/routes/console.php +++ b/routes/console.php @@ -8,6 +8,7 @@ use App\Console\Commands\PruneWebhookLogs; use App\Console\Commands\RecoverStuckPosts; use App\Console\Commands\RefreshExpiringTokens; +use App\Console\Commands\Repurpose\PollRepurposes; use Illuminate\Support\Facades\Schedule; Schedule::command(ProcessScheduledPosts::class)->everyMinute()->withoutOverlapping()->onOneServer(); @@ -16,3 +17,4 @@ Schedule::command(RefreshExpiringTokens::class)->everyFifteenMinutes()->withoutOverlapping()->onOneServer(); Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes()->withoutOverlapping()->onOneServer(); Schedule::command(PruneWebhookLogs::class)->daily()->withoutOverlapping()->onOneServer(); +Schedule::command(PollRepurposes::class)->everyFiveMinutes()->withoutOverlapping()->onOneServer(); diff --git a/tests/Browser/RepurposeAccountHealthTest.php b/tests/Browser/RepurposeAccountHealthTest.php new file mode 100644 index 000000000..3e2fee22c --- /dev/null +++ b/tests/Browser/RepurposeAccountHealthTest.php @@ -0,0 +1,96 @@ +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]); + + $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')) + ->assertPresent('@flow-source-missing') + ->assertNoJavaScriptErrors(); +}); + +test('a switched-off destination is shown as skipped rather than quietly dropped', 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]); + + $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + $paused = SocialAccount::factory()->for($workspace)->create([ + 'platform' => Platform::TikTok, + 'is_active' => false, + ]); + + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $source->id, + 'destinations' => [[ + 'social_account_id' => $paused->id, + 'content_type' => 'tiktok_video', + 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + ]], + ]); + + $this->actingAs($user); + + $page = visit(route('app.repurposes.show', $repurpose)); + + waitForRepurposeHealthTestId($page, 'paused-destinations-note'); + + $page->assertPresent('@paused-destinations-note') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/RepurposeTest.php b/tests/Browser/RepurposeTest.php new file mode 100644 index 000000000..f453da5ea --- /dev/null +++ b/tests/Browser/RepurposeTest.php @@ -0,0 +1,309 @@ +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 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('@repurpose-lifecycle') + ->assertNoJavaScriptErrors(); +}); + +test('a destination is not warned about missing media before there is any', function () { + [$user, $workspace, $source] = repurposeOwnerWithAccounts(); + + $facebook = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Facebook]); + + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $source->id, + 'destinations' => [[ + 'social_account_id' => $facebook->id, + 'content_type' => ContentType::FacebookReel->value, + 'meta' => [], + ]], + ]); + + $this->actingAs($user); + + $page = visit(route('app.repurposes.show', $repurpose)); + + waitForRepurposeTestId($page, 'facebook-settings-toggle'); + + $page->click('@facebook-settings-toggle'); + + usleep(300000); + + $page->assertDontSee('requires_media') + ->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(); +}); + +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}") + ->assertVisible('@flow-source-instagram') + ->click('@source-account-select'); + + usleep(300000); + + $page->click("@source-option-{$facebook->id}"); + + usleep(400000); + + $page->assertVisible("@channel-{$source->id}") + ->assertMissing("@channel-{$facebook->id}") + ->assertVisible('@flow-source-facebook') + ->assertMissing('@flow-source-instagram') + ->assertNoJavaScriptErrors(); +}); + +test('deleting sits behind the menu instead of on the page', function () { + [$user, $workspace, $source] = repurposeOwnerWithAccounts(); + + $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, 'repurpose-menu'); + + $page->assertMissing('@delete-repurpose') + ->click('@repurpose-menu'); + + usleep(400000); + + $page->assertVisible('@delete-repurpose') + ->assertNoJavaScriptErrors(); +}); + +test('the activity list reads as what happened, never as a database id', function () { + [$user, $workspace, $source] = repurposeOwnerWithAccounts(); + + $repurpose = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $source->id, + ]); + + $withoutLink = RepurposeItem::factory()->for($repurpose)->create([ + 'status' => ItemStatus::Skipped, + 'reason' => ItemReason::MediaUrlMissing, + 'source_permalink' => null, + 'source_created_at' => now()->subHour(), + ]); + + $this->actingAs($user); + + $page = visit(route('app.repurposes.show', $repurpose)); + + waitForRepurposeTestId($page, 'tab-activity'); + + $page->click('@tab-activity'); + + usleep(500000); + + $page->assertVisible("@repurpose-item-{$withoutLink->id}") + ->assertDontSee($withoutLink->source_media_id) + ->assertNoJavaScriptErrors(); +}); + +test('a destination missing its required meta saves anyway but blocks activating', function () { + [$user, $workspace, $source, $tiktok] = repurposeOwnerWithAccounts(); + + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'source_social_account_id' => $source->id, + 'destinations' => [], + ]); + + $this->actingAs($user); + + $page = visit(route('app.repurposes.show', $repurpose)); + + waitForRepurposeTestId($page, 'activate-repurpose'); + + $page->assertVisible('@activate-repurpose') + ->assertMissing('@save-destinations') + ->click("@channel-{$tiktok->id}"); + + waitForRepurposeTestId($page, 'repurpose-saved'); + + $saved = $repurpose->fresh()->destinations; + + expect($saved)->toHaveCount(1) + ->and(data_get($saved, '0.social_account_id'))->toBe($tiktok->id) + ->and($page->script('document.querySelector(\'[data-testid="activate-repurpose"]\').disabled'))->toBeTrue(); + + $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/Api/RepurposeApiTest.php b/tests/Feature/Api/RepurposeApiTest.php new file mode 100644 index 000000000..10d05433d --- /dev/null +++ b/tests/Feature/Api/RepurposeApiTest.php @@ -0,0 +1,397 @@ + $this->token, 'workspace' => $this->workspace] = createApiTestToken(); + + $this->source = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::Instagram]); + $this->tiktok = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::TikTok]); +}); + +function apiHeaders(string $token): array +{ + return ['Authorization' => "Bearer {$token}", 'Accept' => 'application/json']; +} + +function tiktokDestinationPayload(SocialAccount $account): array +{ + return [ + 'social_account_id' => $account->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + ]; +} + +test('a repurpose can be created, read, updated and deleted', function () { + $created = $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.store'), [ + 'source_social_account_id' => $this->source->id, + 'source_format' => SourceFormat::Story->value, + 'destinations' => [tiktokDestinationPayload($this->tiktok)], + ]) + ->assertCreated() + ->json(); + + expect($created['source_format'])->toBe('story') + ->and($created['status'])->toBe('draft'); + + $this->withHeaders(apiHeaders($this->token)) + ->getJson(route('api.repurposes.show', $created['id'])) + ->assertOk() + ->assertJsonPath('source_social_account_id', $this->source->id); + + $this->withHeaders(apiHeaders($this->token)) + ->putJson(route('api.repurposes.update', $created['id']), [ + 'source_social_account_id' => $this->source->id, + 'source_format' => SourceFormat::Reel->value, + 'destinations' => [tiktokDestinationPayload($this->tiktok)], + ]) + ->assertOk() + ->assertJsonPath('source_format', 'reel'); + + $this->withHeaders(apiHeaders($this->token)) + ->deleteJson(route('api.repurposes.destroy', $created['id'])) + ->assertNoContent(); + + expect(Repurpose::count())->toBe(0); +}); + +test('destination meta survives a round trip through the api', function () { + $destination = tiktokDestinationPayload($this->tiktok); + + $id = $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.store'), [ + 'source_social_account_id' => $this->source->id, + 'destinations' => [$destination], + ]) + ->assertCreated() + ->json('id'); + + $this->withHeaders(apiHeaders($this->token)) + ->getJson(route('api.repurposes.show', $id)) + ->assertOk() + ->assertJsonPath('destinations.0.meta.privacy_level', 'PUBLIC_TO_EVERYONE'); +}); + +test('a draft accepts a destination that is still missing its required meta', function () { + $id = $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.store'), [ + 'source_social_account_id' => $this->source->id, + 'destinations' => [[ + 'social_account_id' => $this->tiktok->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => [], + ]], + ]) + ->assertCreated() + ->json('id'); + + $this->withHeaders(apiHeaders($this->token)) + ->putJson(route('api.repurposes.update', $id), [ + 'destinations' => [[ + 'social_account_id' => $this->tiktok->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => [], + ]], + ]) + ->assertOk(); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.activate', $id)) + ->assertUnprocessable() + ->assertJsonValidationErrors('destinations'); +}); + +test('an active repurpose cannot drop the meta its destination needs to publish', function () { + $repurpose = Repurpose::factory()->for($this->workspace)->create([ + 'source_social_account_id' => $this->source->id, + 'status' => Status::Active, + 'destinations' => [tiktokDestinationPayload($this->tiktok)], + ]); + + $this->withHeaders(apiHeaders($this->token)) + ->putJson(route('api.repurposes.update', $repurpose), [ + 'destinations' => [[ + 'social_account_id' => $this->tiktok->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => [], + ]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('destinations.0.meta.privacy_level'); +}); + +test('a destination format that cannot carry a video is rejected', function () { + $pinterest = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::Pinterest]); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.store'), [ + 'source_social_account_id' => $this->source->id, + 'destinations' => [[ + 'social_account_id' => $pinterest->id, + 'content_type' => ContentType::PinterestPin->value, + ]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('destinations.0.content_type'); +}); + +test('the index lists the workspace repurposes', function () { + foreach ([SourceFormat::Reel, SourceFormat::Story] as $format) { + Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'source_format' => $format, + ]); + } + + $this->withHeaders(apiHeaders($this->token)) + ->getJson(route('api.repurposes.index')) + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('meta.per_page', (int) config('app.pagination.default')) + ->assertJsonPath('data.0.source_account.id', $this->source->id); +}); + +test('the status transitions are exposed', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'destinations' => [tiktokDestinationPayload($this->tiktok)], + ]); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.activate', $repurpose)) + ->assertOk() + ->assertJsonPath('status', Status::Active->value); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.pause', $repurpose)) + ->assertOk() + ->assertJsonPath('status', Status::Paused->value); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.resume', $repurpose)) + ->assertOk() + ->assertJsonPath('status', Status::Active->value); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.disable', $repurpose)) + ->assertOk() + ->assertJsonPath('status', Status::Disabled->value); +}); + +test('activating without a destination fails validation', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.activate', $repurpose)) + ->assertUnprocessable(); +}); + +test('items are paginated at the documented page size', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + RepurposeItem::factory()->count(30)->for($repurpose)->create(['status' => ItemStatus::Published]); + + $this->withHeaders(apiHeaders($this->token)) + ->getJson(route('api.repurposes.items', $repurpose)) + ->assertOk() + ->assertJsonCount((int) config('app.pagination.default'), 'data') + ->assertJsonPath('meta.total', 30); +}); + +test('a repurpose from another workspace is not reachable', function () { + $stranger = Repurpose::factory()->create(); + + $this->withHeaders(apiHeaders($this->token)) + ->getJson(route('api.repurposes.show', $stranger)) + ->assertForbidden(); +}); + +test('an account from another workspace is rejected as a source', function () { + $stranger = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.store'), ['source_social_account_id' => $stranger->id]) + ->assertUnprocessable() + ->assertJsonValidationErrors('source_social_account_id'); +}); + +test('an account from another workspace is rejected as a destination', function () { + $stranger = SocialAccount::factory()->create(['platform' => Platform::TikTok]); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.store'), [ + 'source_social_account_id' => $this->source->id, + 'destinations' => [tiktokDestinationPayload($stranger)], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('destinations.0.social_account_id'); +}); + +test('a network we cannot download from is rejected as a source', function () { + $tiktokSource = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::YouTube]); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.store'), ['source_social_account_id' => $tiktokSource->id]) + ->assertUnprocessable() + ->assertJsonValidationErrors('source_social_account_id'); +}); + +test('a content type from another network is rejected for a destination', function () { + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.store'), [ + 'source_social_account_id' => $this->source->id, + 'destinations' => [[ + 'social_account_id' => $this->tiktok->id, + 'content_type' => ContentType::YouTubeShort->value, + ]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('destinations.0.content_type'); +}); + +test('the api refuses a transition the interface would never offer', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'destinations' => [tiktokDestinationPayload($this->tiktok)], + ]); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.pause', $repurpose)) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.disable', $repurpose)) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.activate', $repurpose)) + ->assertOk(); + + $watermark = $repurpose->fresh()->activated_at; + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.activate', $repurpose)) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + + expect($repurpose->fresh()->activated_at->equalTo($watermark))->toBeTrue(); + + $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.resume', $repurpose)) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); +}); + +test('the publishing mode round-trips through the api', function () { + $created = $this->withHeaders(apiHeaders($this->token)) + ->postJson(route('api.repurposes.store'), [ + 'source_social_account_id' => $this->source->id, + 'publish_mode' => PublishMode::Draft->value, + ]) + ->assertStatus(Response::HTTP_CREATED) + ->assertJsonPath('publish_mode', PublishMode::Draft->value); + + $this->withHeaders(apiHeaders($this->token)) + ->putJson(route('api.repurposes.update', $created->json('id')), [ + 'publish_mode' => PublishMode::Publish->value, + ]) + ->assertOk() + ->assertJsonPath('publish_mode', PublishMode::Publish->value); + + $this->withHeaders(apiHeaders($this->token)) + ->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); + + $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); +}); + +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); +}); + +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/Api/WebhookApiTest.php b/tests/Feature/Api/WebhookApiTest.php index 59b8a079e..804eff253 100644 --- a/tests/Feature/Api/WebhookApiTest.php +++ b/tests/Feature/Api/WebhookApiTest.php @@ -371,11 +371,11 @@ ->not->toBe('whsec_oldsecretoldsecretoldsecre'); }); -test('list logs paginates at 15', function () { +test('list logs paginates at the configured page size', function () { $webhook = Webhook::factory()->create([ 'workspace_id' => $this->workspace->id, ]); - WebhookLog::factory()->count(16)->create([ + WebhookLog::factory()->count(30)->create([ 'webhook_id' => $webhook->id, ]); @@ -386,8 +386,8 @@ ['HTTP_HOST' => 'api.trypost.test'] ) ->assertOk() - ->assertJsonCount(15, 'data') - ->assertJsonPath('meta.per_page', 15) + ->assertJsonCount((int) config('app.pagination.default'), 'data') + ->assertJsonPath('meta.per_page', (int) config('app.pagination.default')) ->assertJsonPath('data.0.event_type', EventType::PostPublished->value); }); diff --git a/tests/Feature/Commands/CheckUpcomingPostConnectionsTest.php b/tests/Feature/Commands/CheckUpcomingPostConnectionsTest.php index 932af396f..e161f7b15 100644 --- a/tests/Feature/Commands/CheckUpcomingPostConnectionsTest.php +++ b/tests/Feature/Commands/CheckUpcomingPostConnectionsTest.php @@ -32,7 +32,6 @@ } $this->artisan('social:check-upcoming-connections') - ->expectsOutput('Dispatched 1 upcoming-post connection checks.') ->assertSuccessful(); Queue::assertPushed(VerifyUpcomingPostConnections::class, fn ($job) => $job->workspaceId === $workspace->id); @@ -79,7 +78,6 @@ ]); $this->artisan('social:check-upcoming-connections') - ->expectsOutput('Dispatched 0 upcoming-post connection checks.') ->assertSuccessful(); Queue::assertNothingPushed(); @@ -103,7 +101,6 @@ ]); $this->artisan('social:check-upcoming-connections') - ->expectsOutput('Dispatched 0 upcoming-post connection checks.') ->assertSuccessful(); Queue::assertNothingPushed(); @@ -130,7 +127,6 @@ ]); $this->artisan('social:check-upcoming-connections') - ->expectsOutput('Dispatched 0 upcoming-post connection checks.') ->assertSuccessful(); Queue::assertNothingPushed(); @@ -154,7 +150,6 @@ ]); $this->artisan('social:check-upcoming-connections') - ->expectsOutput('Dispatched 0 upcoming-post connection checks.') ->assertSuccessful(); Queue::assertNothingPushed(); diff --git a/tests/Feature/Commands/RecoverStuckPostsTest.php b/tests/Feature/Commands/RecoverStuckPostsTest.php index 9c168bfb7..210be3d5f 100644 --- a/tests/Feature/Commands/RecoverStuckPostsTest.php +++ b/tests/Feature/Commands/RecoverStuckPostsTest.php @@ -259,7 +259,6 @@ ]); $this->artisan('social:recover-stuck-posts') - ->expectsOutput('Recovered 0 stuck posts.') ->assertSuccessful(); $platform->refresh(); @@ -286,7 +285,6 @@ ]); $this->artisan('social:recover-stuck-posts') - ->expectsOutput('Recovered 0 stuck posts.') ->assertSuccessful(); $platform->refresh(); @@ -328,7 +326,6 @@ ]); $this->artisan('social:recover-stuck-posts') - ->expectsOutput('Recovered 0 stuck posts.') ->assertSuccessful(); $stalePlatform->refresh(); diff --git a/tests/Feature/Commands/RefreshExpiringTokensTest.php b/tests/Feature/Commands/RefreshExpiringTokensTest.php index 5889814fb..f4340c5ee 100644 --- a/tests/Feature/Commands/RefreshExpiringTokensTest.php +++ b/tests/Feature/Commands/RefreshExpiringTokensTest.php @@ -160,6 +160,5 @@ // RefreshSocialToken is unique per account, so a second dispatch while the // first is in flight is silently discarded. dispatch() still returns a // PendingDispatch either way, so a "dispatched" count would be a guess. - $this->artisan('social:refresh-expiring-tokens') - ->expectsOutput('1 accounts due for a token refresh.'); + $this->artisan('social:refresh-expiring-tokens')->assertSuccessful(); }); diff --git a/tests/Feature/Commands/RetryFailedPostTest.php b/tests/Feature/Commands/RetryFailedPostTest.php index 97c6e8af8..b39734a14 100644 --- a/tests/Feature/Commands/RetryFailedPostTest.php +++ b/tests/Feature/Commands/RetryFailedPostTest.php @@ -72,7 +72,6 @@ $this->artisan('posts:retry', ['post' => $this->post->id]) ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') - ->expectsOutput('2 publish attempt(s) queued.') ->assertSuccessful(); expect($this->post->fresh()->status)->toBe(PostStatus::Publishing) @@ -228,7 +227,6 @@ $this->artisan('posts:retry', ['post' => $this->post->id]) ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'no') - ->expectsOutput('Retry cancelled.') ->assertSuccessful(); expect($this->post->fresh()->status)->toBe(PostStatus::PartiallyPublished) diff --git a/tests/Feature/Mcp/RepurposeToolTest.php b/tests/Feature/Mcp/RepurposeToolTest.php new file mode 100644 index 000000000..d39a09353 --- /dev/null +++ b/tests/Feature/Mcp/RepurposeToolTest.php @@ -0,0 +1,407 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->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) + ->tool(CreateRepurposeTool::class, [ + 'source_social_account_id' => $this->source->id, + 'source_format' => SourceFormat::Story->value, + 'destinations' => [tiktokDestinationForMcp($this->tiktok)], + ]); + + $response->assertOk(); + + $repurpose = Repurpose::sole(); + + expect($repurpose->source_format)->toBe(SourceFormat::Story) + ->and($repurpose->status)->toBe(Status::Draft) + ->and($repurpose->destinations)->toEqual([tiktokDestinationForMcp($this->tiktok)]); +}); + +test('destination meta survives a read back through the get tool', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'destinations' => [tiktokDestinationForMcp($this->tiktok)], + ]); + + TryPostServer::actingAs($this->user) + ->tool(GetRepurposeTool::class, ['repurpose_id' => $repurpose->id]) + ->assertOk() + ->assertSee('PUBLIC_TO_EVERYONE'); +}); + +test('the list tool returns the workspace repurposes and nobody else\'s', function () { + $reel = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'source_format' => SourceFormat::Reel, + 'created_at' => now()->subHour(), + ]); + + $story = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'source_format' => SourceFormat::Story, + 'created_at' => now(), + ]); + + $otherWorkspace = Workspace::factory()->create(); + Repurpose::factory()->create([ + 'workspace_id' => $otherWorkspace->id, + 'source_social_account_id' => SocialAccount::factory()->create([ + 'workspace_id' => $otherWorkspace->id, + 'platform' => Platform::Instagram, + ])->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(ListRepurposesTool::class, []) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->has('repurposes', 2) + ->where('total', 2) + ->where('repurposes.0.id', $story->id) + ->where('repurposes.0.source_format', SourceFormat::Story->value) + ->where('repurposes.1.id', $reel->id) + ->where('repurposes.1.source_format', SourceFormat::Reel->value) + ->etc()); +}); + +test('updating replaces the destinations and the watched format', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'source_format' => SourceFormat::Reel, + ]); + + TryPostServer::actingAs($this->user) + ->tool(UpdateRepurposeTool::class, [ + 'repurpose_id' => $repurpose->id, + 'source_social_account_id' => $this->source->id, + 'source_format' => SourceFormat::Video->value, + 'destinations' => [tiktokDestinationForMcp($this->tiktok)], + ]) + ->assertOk(); + + expect($repurpose->fresh()->source_format)->toBe(SourceFormat::Video) + ->and($repurpose->fresh()->destinations)->toEqual([tiktokDestinationForMcp($this->tiktok)]); +}); + +test('activate and pause move the status', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'destinations' => [tiktokDestinationForMcp($this->tiktok)], + ]); + + TryPostServer::actingAs($this->user) + ->tool(ActivateRepurposeTool::class, ['repurpose_id' => $repurpose->id]) + ->assertOk(); + + expect($repurpose->fresh()->status)->toBe(Status::Active); + + TryPostServer::actingAs($this->user) + ->tool(PauseRepurposeTool::class, ['repurpose_id' => $repurpose->id]) + ->assertOk(); + + expect($repurpose->fresh()->status)->toBe(Status::Paused); +}); + +test('activating without a destination reports an error instead of activating', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(ActivateRepurposeTool::class, ['repurpose_id' => $repurpose->id]) + ->assertHasErrors(); + + expect($repurpose->fresh()->status)->toBe(Status::Draft); +}); + +test('the items tool exposes why a video was skipped', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + RepurposeItem::factory()->for($repurpose)->create([ + 'status' => ItemStatus::Skipped, + 'reason' => 'published_via_trypost', + ]); + + TryPostServer::actingAs($this->user) + ->tool(ListRepurposeItemsTool::class, ['repurpose_id' => $repurpose->id]) + ->assertOk() + ->assertSee('published_via_trypost'); +}); + +test('a repurpose from another workspace is not reachable', function () { + $stranger = Repurpose::factory()->create(); + + TryPostServer::actingAs($this->user) + ->tool(GetRepurposeTool::class, ['repurpose_id' => $stranger->id]) + ->assertHasErrors(); +}); + +test('deleting removes the repurpose', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + TryPostServer::actingAs($this->user) + ->tool(DeleteRepurposeTool::class, ['repurpose_id' => $repurpose->id]) + ->assertOk(); + + expect(Repurpose::count())->toBe(0); +}); + +test('an account from another workspace is rejected as a destination', function () { + $stranger = SocialAccount::factory()->create(['platform' => Platform::TikTok]); + + TryPostServer::actingAs($this->user) + ->tool(CreateRepurposeTool::class, [ + 'source_social_account_id' => $this->source->id, + 'destinations' => [tiktokDestinationForMcp($stranger)], + ]) + ->assertHasErrors(); + + expect(Repurpose::count())->toBe(0); +}); + +test('the list tool pages instead of returning everything at once', function () { + config()->set('app.pagination.default', 1); + + foreach ([SourceFormat::Reel, SourceFormat::Story] as $format) { + Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'source_format' => $format, + ]); + } + + TryPostServer::actingAs($this->user) + ->tool(ListRepurposesTool::class, []) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->has('repurposes', 1) + ->where('total', 2) + ->where('per_page', 1) + ->where('current_page', 1) + ->where('last_page', 2) + ->etc()); + + TryPostServer::actingAs($this->user) + ->tool(ListRepurposesTool::class, ['page' => 2]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->has('repurposes', 1) + ->where('current_page', 2) + ->etc()); +}); + +test('the publishing mode is settable through mcp', function () { + TryPostServer::actingAs($this->user) + ->tool(CreateRepurposeTool::class, [ + 'source_social_account_id' => $this->source->id, + 'publish_mode' => PublishMode::Draft->value, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('publish_mode', PublishMode::Draft->value) + ->etc()); + + 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]); + + TryPostServer::actingAs($this->user) + ->tool(ActivateRepurposeTool::class, ['repurpose_id' => $repurpose->id]) + ->assertHasErrors(); + + 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, + ]); + + TryPostServer::actingAs($this->user) + ->tool(ListRepurposeItemsTool::class, ['repurpose_id' => $repurpose->id]) + ->assertOk() + ->assertSee(PostPlatformStatus::Published->value); +}); + +test('the update tool accepts a switched-off account as a destination', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'destinations' => [tiktokDestinationForMcp($this->tiktok)], + ]); + + $this->tiktok->update(['is_active' => false]); + + TryPostServer::actingAs($this->user) + ->tool(UpdateRepurposeTool::class, [ + 'repurpose_id' => $repurpose->id, + 'destinations' => [tiktokDestinationForMcp($this->tiktok)], + ]) + ->assertOk(); + + expect($repurpose->fresh()->destinations)->toHaveCount(1); +}); + +test('the update tool keeps a draft destination that is still missing its 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' => [], + ]], + ]) + ->assertOk(); + + expect($repurpose->fresh()->destinations)->toHaveCount(1); + + TryPostServer::actingAs($this->user) + ->tool(ActivateRepurposeTool::class, ['repurpose_id' => $repurpose->id]) + ->assertHasErrors(); + + expect($repurpose->fresh()->status)->toBe(Status::Draft); +}); + +test('the update tool refuses to drop the board an active repurpose publishes with', function () { + $pinterest = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::Pinterest]); + + $repurpose = Repurpose::factory()->active()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'destinations' => [[ + 'social_account_id' => $pinterest->id, + 'content_type' => ContentType::PinterestVideoPin->value, + 'meta' => ['board_id' => 'board-1'], + ]], + ]); + + 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(data_get($repurpose->fresh()->destinations, '0.meta.board_id'))->toBe('board-1'); +}); + +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/AccountHealthTest.php b/tests/Feature/Repurpose/AccountHealthTest.php new file mode 100644 index 000000000..5dbb6ab98 --- /dev/null +++ b/tests/Feature/Repurpose/AccountHealthTest.php @@ -0,0 +1,825 @@ +create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $account = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + + return [$workspace, $user, $account]; +} + +/** + * @return array + */ +function healthDestination(Workspace $workspace): array +{ + $account = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::TikTok]); + + return [ + 'social_account_id' => $account->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + ]; +} + +test('deleting the source account leaves the repurpose and its history intact', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $account = SocialAccount::factory()->for($workspace)->create(); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $account->id, + ]); + + $account->delete(); + + expect(Repurpose::query()->whereKey($repurpose->id)->exists())->toBeTrue() + ->and($repurpose->fresh()->source_social_account_id)->toBeNull(); +}); + +test('applyIfPossible returns null instead of throwing when the status moved on', function () { + $repurpose = Repurpose::factory()->create(['status' => Status::Paused]); + + $result = RepurposeTransition::applyIfPossible( + $repurpose, + [Status::Active], + fn (Repurpose $locked) => $locked->update(['status' => Status::Paused]), + ); + + expect($result)->toBeNull(); +}); + +test('applyIfPossible applies the change and returns the fresh model', function () { + $repurpose = Repurpose::factory()->create(['status' => Status::Active]); + + $result = RepurposeTransition::applyIfPossible( + $repurpose, + [Status::Active], + fn (Repurpose $locked) => $locked->update(['status' => Status::Paused]), + ); + + expect($result?->status)->toBe(Status::Paused); +}); + +test('a deactivated destination does not block activation while another still works', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $live = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Mastodon]); + $off = SocialAccount::factory()->for($workspace)->create([ + 'platform' => Platform::Threads, + 'is_active' => false, + ]); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Draft, + 'destinations' => [ + ['social_account_id' => $live->id, 'content_type' => ContentType::MastodonPost->value, 'meta' => []], + ['social_account_id' => $off->id, 'content_type' => ContentType::ThreadsPost->value, 'meta' => []], + ], + ]); + + expect(ActivateRepurpose::execute($repurpose)->status)->toBe(Status::Active); +}); + +test('activation is refused when no destination is usable', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $off = SocialAccount::factory()->for($workspace)->create([ + 'platform' => Platform::Threads, + 'is_active' => false, + ]); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Draft, + 'destinations' => [ + ['social_account_id' => $off->id, 'content_type' => ContentType::ThreadsPost->value, 'meta' => []], + ], + ]); + + expect(fn () => ActivateRepurpose::execute($repurpose))->toThrow(ValidationException::class); +}); + +test('activation is refused when the source account is disconnected', function () { + [$workspace, $user, $source] = healthWorkspace(); + $source->update(['status' => AccountStatus::Disconnected]); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Draft, + 'destinations' => [healthDestination($workspace)], + ]); + + expect(fn () => ActivateRepurpose::execute($repurpose))->toThrow(ValidationException::class); +}); + +test('activation is refused when the source account was removed', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Draft, + 'destinations' => [healthDestination($workspace)], + ]); + + $source->delete(); + + expect(fn () => ActivateRepurpose::execute($repurpose->fresh()))->toThrow(ValidationException::class); +}); + +test('editing an active repurpose is not blocked by a deactivated destination', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $live = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Mastodon]); + $off = SocialAccount::factory()->for($workspace)->create([ + 'platform' => Platform::Threads, + 'is_active' => false, + ]); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Active, + 'destinations' => [ + ['social_account_id' => $live->id, 'content_type' => ContentType::MastodonPost->value, 'meta' => []], + ['social_account_id' => $off->id, 'content_type' => ContentType::ThreadsPost->value, 'meta' => []], + ], + ]); + + $updated = UpdateRepurpose::execute($repurpose, ['publish_mode' => PublishMode::Draft->value]); + + expect($updated->publish_mode)->toBe(PublishMode::Draft); +}); + +test('resuming a user pause keeps the watermark', function () { + [$workspace, $user, $source] = healthWorkspace(); + $watermark = now()->subDays(3); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Paused, + 'paused_reason' => null, + 'activated_at' => $watermark, + 'destinations' => [healthDestination($workspace)], + ]); + + expect(ResumeRepurpose::execute($repurpose)->activated_at->timestamp) + ->toBe($watermark->timestamp); +}); + +test('resuming a system pause starts from now and clears the reason', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Paused, + 'paused_reason' => PauseReason::SourceUnavailable, + 'activated_at' => now()->subDays(3), + 'destinations' => [healthDestination($workspace)], + ]); + + $resumed = ResumeRepurpose::execute($repurpose); + + expect($resumed->activated_at->isToday())->toBeTrue() + ->and($resumed->paused_reason)->toBeNull() + ->and($resumed->next_poll_at)->toBeNull(); +}); + +test('resuming is refused while the source is still unusable', function () { + [$workspace, $user, $source] = healthWorkspace(); + $source->update(['is_active' => false]); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Paused, + 'paused_reason' => PauseReason::SourceUnavailable, + 'destinations' => [healthDestination($workspace)], + ]); + + expect(fn () => ResumeRepurpose::execute($repurpose))->toThrow(ValidationException::class); +}); + +test('deleting the source account pauses the repurpose', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Active, + 'destinations' => [healthDestination($workspace)], + ]); + + $source->delete(); + + expect($repurpose->fresh()->status)->toBe(Status::Paused) + ->and($repurpose->fresh()->paused_reason)->toBe(PauseReason::SourceRemoved); +}); + +test('a source going token expired pauses the repurpose', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Active, + 'destinations' => [healthDestination($workspace)], + ]); + + $source->update(['status' => AccountStatus::TokenExpired]); + + expect($repurpose->fresh()->paused_reason)->toBe(PauseReason::SourceUnavailable); +}); + +test('deactivating the source pauses the repurpose', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Active, + 'destinations' => [healthDestination($workspace)], + ]); + + $source->update(['is_active' => false]); + + expect($repurpose->fresh()->paused_reason)->toBe(PauseReason::SourceUnavailable); +}); + +test('a draft repurpose is left alone when its source dies', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Draft, + 'destinations' => [healthDestination($workspace)], + ]); + + $source->update(['is_active' => false]); + + expect($repurpose->fresh()->status)->toBe(Status::Draft) + ->and($repurpose->fresh()->paused_reason)->toBeNull(); +}); + +test('a repurpose the user paused does not acquire a system reason', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Paused, + 'paused_reason' => null, + 'destinations' => [healthDestination($workspace)], + ]); + + $source->update(['is_active' => false]); + + expect($repurpose->fresh()->paused_reason)->toBeNull(); +}); + +test('an unrelated account update does not touch the repurpose', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Active, + 'destinations' => [healthDestination($workspace)], + ]); + + $source->update(['last_used_at' => now()]); + + expect($repurpose->fresh()->status)->toBe(Status::Active); +}); + +test('deleting a destination account prunes it from the repurpose', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $keep = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Mastodon]); + $drop = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Threads]); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Active, + 'destinations' => [ + ['social_account_id' => $keep->id, 'content_type' => ContentType::MastodonPost->value, 'meta' => []], + ['social_account_id' => $drop->id, 'content_type' => ContentType::ThreadsPost->value, 'meta' => []], + ], + ]); + + $drop->delete(); + + $destinations = $repurpose->fresh()->destinations; + + expect($destinations)->toHaveCount(1) + ->and(data_get($destinations, '0.social_account_id'))->toBe($keep->id) + ->and($repurpose->fresh()->status)->toBe(Status::Active); +}); + +test('deleting the last destination pauses the repurpose', 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::Active, + 'destinations' => [ + ['social_account_id' => $only->id, 'content_type' => ContentType::ThreadsPost->value, 'meta' => []], + ], + ]); + + $only->delete(); + + expect($repurpose->fresh()->destinations)->toBe([]) + ->and($repurpose->fresh()->paused_reason)->toBe(PauseReason::NoDestinations); +}); + +test('reconnecting a LinkedIn destination as a page realigns its content type', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $linkedin = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::LinkedIn]); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Active, + 'destinations' => [ + ['social_account_id' => $linkedin->id, 'content_type' => ContentType::LinkedInPost->value, 'meta' => []], + ], + ]); + + $linkedin->update(['platform' => Platform::LinkedInPage]); + + expect(data_get($repurpose->fresh()->destinations, '0.content_type')) + ->toBe(ContentType::LinkedInPagePost->value); +}); + +test('a deactivated destination is left in place', function () { + [$workspace, $user, $source] = healthWorkspace(); + $off = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Threads]); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Active, + 'destinations' => [ + ['social_account_id' => $off->id, 'content_type' => ContentType::ThreadsPost->value, 'meta' => []], + ], + ]); + + $off->update(['is_active' => false]); + + expect($repurpose->fresh()->destinations)->toHaveCount(1) + ->and($repurpose->fresh()->status)->toBe(Status::Active); +}); + +test('reconnecting the source resumes the repurpose from now', function () { + [$workspace, $user, $source] = healthWorkspace(); + $source->update(['status' => AccountStatus::TokenExpired]); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Paused, + 'paused_reason' => PauseReason::SourceUnavailable, + 'activated_at' => now()->subDays(2), + 'destinations' => [healthDestination($workspace)], + ]); + + $source->update(['status' => AccountStatus::Connected]); + + $fresh = $repurpose->fresh(); + + expect($fresh->status)->toBe(Status::Active) + ->and($fresh->paused_reason)->toBeNull() + ->and($fresh->activated_at->isToday())->toBeTrue(); +}); + +test('a user pause is never auto-resumed', function () { + [$workspace, $user, $source] = healthWorkspace(); + $source->update(['is_active' => false]); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Paused, + 'paused_reason' => null, + 'destinations' => [healthDestination($workspace)], + ]); + + $source->update(['is_active' => true]); + + expect($repurpose->fresh()->status)->toBe(Status::Paused); +}); + +test('a repurpose with no destinations left is not auto-resumed', function () { + [$workspace, $user, $source] = healthWorkspace(); + $source->update(['status' => AccountStatus::TokenExpired]); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Paused, + 'paused_reason' => PauseReason::NoDestinations, + 'destinations' => [], + ]); + + $source->update(['status' => AccountStatus::Connected]); + + expect($repurpose->fresh()->status)->toBe(Status::Paused); +}); + +test('disconnecting an account 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) + ->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])); +}); + +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])); +}); + +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' => []], + ], + ]); + + $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 () { + 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->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(); + + $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); +}); + +test('picking a new source for an orphan and resuming starts from now', function () { + [$workspace, $user, $source] = healthWorkspace(); + $destination = healthDestination($workspace); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Active, + 'activated_at' => now()->subDays(30), + 'destinations' => [$destination], + ]); + + $source->delete(); + + expect($repurpose->fresh()->paused_reason)->toBe(PauseReason::SourceRemoved) + ->and($repurpose->fresh()->source_social_account_id)->toBeNull(); + + $replacement = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + + UpdateRepurpose::execute($repurpose, ['source_social_account_id' => $replacement->id]); + + $resumed = ResumeRepurpose::execute($repurpose->fresh()); + + expect($resumed->status)->toBe(Status::Active) + ->and($resumed->paused_reason)->toBeNull() + ->and($resumed->activated_at->isToday())->toBeTrue(); +}); + +test('an orphan cannot be resumed before a new source is picked', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Active, + 'destinations' => [healthDestination($workspace)], + ]); + + $source->delete(); + + expect(fn () => ResumeRepurpose::execute($repurpose->fresh())) + ->toThrow(ValidationException::class); +}); + +test('a failure inside the sync never breaks the account operation', function () { + Log::spy(); + + $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]); + + Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $account->id, + 'status' => Status::Active, + 'destinations' => ['not-an-object'], + ]); + + $this->actingAs($user) + ->delete(route('app.accounts.disconnect', $account)) + ->assertRedirect(); + + expect(SocialAccount::query()->whereKey($account->id)->exists())->toBeFalse(); + + Log::shouldHaveReceived('error')->withArgs(fn (string $message): bool => $message === 'Repurpose account sync failed'); +}); + +test('turning a system-paused repurpose off clears the reason with it', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Paused, + 'paused_reason' => PauseReason::SourceUnavailable, + 'destinations' => [healthDestination($workspace)], + ]); + + $disabled = DisableRepurpose::execute($repurpose); + + expect($disabled->status)->toBe(Status::Disabled) + ->and($disabled->paused_reason)->toBeNull(); +}); + +test('activating clears any reason left from an earlier stop', function () { + [$workspace, $user, $source] = healthWorkspace(); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Disabled, + 'paused_reason' => PauseReason::NoDestinations, + 'destinations' => [healthDestination($workspace)], + ]); + + $activated = ActivateRepurpose::execute($repurpose); + + expect($activated->status)->toBe(Status::Active) + ->and($activated->paused_reason)->toBeNull(); +}); + +test('changing the source of a never-activated repurpose invents no watermark', function () { + [$workspace, $user, $source] = healthWorkspace(); + $other = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Facebook]); + + $repurpose = Repurpose::factory()->for($workspace)->create([ + 'source_social_account_id' => $source->id, + 'status' => Status::Draft, + 'activated_at' => null, + 'destinations' => [healthDestination($workspace)], + ]); + + UpdateRepurpose::execute($repurpose, ['source_social_account_id' => $other->id]); + + expect($repurpose->fresh()->activated_at)->toBeNull(); +}); + +test('a draft saves a destination that is still missing its required meta', 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' => [], + ]], + ]) + ->assertSessionHasNoErrors(); + + expect($repurpose->fresh()->destinations)->toHaveCount(1); +}); + +test('a draft missing its required meta cannot be activated', 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, + 'destinations' => [[ + 'social_account_id' => $pinterest->id, + 'content_type' => ContentType::PinterestVideoPin->value, + 'meta' => [], + ]], + ]); + + $this->actingAs($user) + ->post(route('app.repurposes.activate', $repurpose)) + ->assertSessionHasErrors('destinations'); + + expect($repurpose->fresh()->status)->toBe(Status::Draft); +}); + +test('an active repurpose cannot drop the meta its destination needs to publish', 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::Active, + 'destinations' => [[ + 'social_account_id' => $tiktok->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + ]], + ]); + + $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'); + + expect(data_get($repurpose->fresh()->destinations, '0.meta.privacy_level'))->toBe('PUBLIC_TO_EVERYONE'); +}); diff --git a/tests/Feature/Repurpose/ActionsTest.php b/tests/Feature/Repurpose/ActionsTest.php new file mode 100644 index 000000000..2928dd317 --- /dev/null +++ b/tests/Feature/Repurpose/ActionsTest.php @@ -0,0 +1,411 @@ +create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $account = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + + return [$workspace, $user, $account]; +} + +function tiktokDestination(Workspace $workspace): array +{ + $account = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::TikTok]); + + return [ + 'social_account_id' => $account->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + ]; +} + +test('a repurpose is created as a draft with its source account', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $repurpose = CreateRepurpose::execute($workspace, $user, ['source_social_account_id' => $account->id]); + + expect($repurpose->status)->toBe(Status::Draft) + ->and($repurpose->source_social_account_id)->toBe($account->id) + ->and($repurpose->destinations)->toBe([]) + ->and($repurpose->activated_at)->toBeNull(); +}); + +test('a second repurpose for the same source account and format is rejected', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + CreateRepurpose::execute($workspace, $user, ['source_social_account_id' => $account->id]); + + expect(fn () => CreateRepurpose::execute($workspace, $user, ['source_social_account_id' => $account->id])) + ->toThrow(ValidationException::class); +}); + +test('one account can feed one repurpose per watched format', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + foreach ([SourceFormat::Reel, SourceFormat::Video, SourceFormat::Story] as $format) { + CreateRepurpose::execute($workspace, $user, [ + 'source_social_account_id' => $account->id, + 'source_format' => $format->value, + ]); + } + + expect(Repurpose::where('source_social_account_id', $account->id)->count())->toBe(3); +}); + +test('changing the watched format resets the watermark', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $repurpose = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'source_format' => SourceFormat::Reel, + 'destinations' => [tiktokDestination($workspace)], + 'activated_at' => now()->subMonth(), + ]); + + $updated = UpdateRepurpose::execute($repurpose, ['source_format' => SourceFormat::Story->value]); + + expect($updated->source_format)->toBe(SourceFormat::Story) + ->and($updated->activated_at->isToday())->toBeTrue(); +}); + +test('two accounts on the same network each get their own repurpose', function () { + config()->set('trypost.allow_multiple_social_accounts', true); + + [$workspace, $user, $first] = repurposeWorkspace(); + $second = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + + CreateRepurpose::execute($workspace, $user, ['source_social_account_id' => $first->id]); + CreateRepurpose::execute($workspace, $user, ['source_social_account_id' => $second->id]); + + expect(Repurpose::where('workspace_id', $workspace->id)->count())->toBe(2); +}); + +test('destination meta survives create and update', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + $destination = tiktokDestination($workspace); + + $repurpose = CreateRepurpose::execute($workspace, $user, [ + 'source_social_account_id' => $account->id, + 'destinations' => [$destination], + ]); + + expect($repurpose->fresh()->destinations)->toEqual([$destination]); + + $updated = UpdateRepurpose::execute($repurpose, ['destinations' => [$destination]]); + + expect($updated->destinations)->toEqual([$destination]); +}); + +test('a destination that cannot publish without meta blocks activation', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + foreach ([ + [Platform::TikTok, ContentType::TikTokVideo], + [Platform::Pinterest, ContentType::PinterestVideoPin], + [Platform::Discord, ContentType::DiscordMessage], + ] as [$platform, $contentType]) { + $destination = SocialAccount::factory()->for($workspace)->create(['platform' => $platform]); + + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'source_format' => SourceFormat::Reel, + 'destinations' => [[ + 'social_account_id' => $destination->id, + 'content_type' => $contentType->value, + 'meta' => [], + ]], + ]); + + expect(fn () => ActivateRepurpose::execute($repurpose)) + ->toThrow(ValidationException::class); + + $repurpose->delete(); + $destination->delete(); + } +}); + +test('a destination carrying its required meta activates', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'destinations' => [tiktokDestination($workspace)], + ]); + + expect(ActivateRepurpose::execute($repurpose)->status)->toBe(Status::Active); +}); + +test('a destination that needs no meta activates', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $telegram = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Telegram]); + + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'destinations' => [[ + 'social_account_id' => $telegram->id, + 'content_type' => ContentType::TelegramPost->value, + 'meta' => [], + ]], + ]); + + expect(ActivateRepurpose::execute($repurpose)->status)->toBe(Status::Active); +}); + +test('activation requires at least one destination', function () { + $repurpose = Repurpose::factory()->create(); + + expect(fn () => ActivateRepurpose::execute($repurpose))->toThrow(ValidationException::class); +}); + +test('activation stamps the watermark', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'destinations' => [tiktokDestination($workspace)], + ]); + + $activated = ActivateRepurpose::execute($repurpose); + + expect($activated->status)->toBe(Status::Active) + ->and($activated->activated_at)->not->toBeNull(); +}); + +test('pausing keeps the watermark and resuming does not move it', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $repurpose = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'destinations' => [tiktokDestination($workspace)], + ]); + $watermark = $repurpose->activated_at; + + $paused = PauseRepurpose::execute($repurpose); + + expect($paused->status)->toBe(Status::Paused) + ->and($paused->activated_at->equalTo($watermark))->toBeTrue(); + + $resumed = ResumeRepurpose::execute($paused); + + expect($resumed->status)->toBe(Status::Active) + ->and($resumed->activated_at->equalTo($watermark))->toBeTrue(); +}); + +test('disabling clears the watermark so re-activation starts fresh', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $repurpose = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'destinations' => [tiktokDestination($workspace)], + ]); + + $disabled = DisableRepurpose::execute($repurpose); + + expect($disabled->status)->toBe(Status::Disabled) + ->and($disabled->activated_at)->toBeNull(); + + $reactivated = ActivateRepurpose::execute($disabled); + + expect($reactivated->activated_at->isToday())->toBeTrue(); +}); + +test('changing the source account resets the watermark', function () { + config()->set('trypost.allow_multiple_social_accounts', true); + + [$workspace, $user, $account] = repurposeWorkspace(); + + $repurpose = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'destinations' => [tiktokDestination($workspace)], + 'activated_at' => now()->subMonth(), + ]); + $newAccount = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + + $updated = UpdateRepurpose::execute($repurpose, ['source_social_account_id' => $newAccount->id]); + + expect($updated->source_social_account_id)->toBe($newAccount->id) + ->and($updated->activated_at->isToday())->toBeTrue(); +}); + +test('deleting a repurpose removes its items but keeps the posts it created', function () { + $repurpose = Repurpose::factory()->create(); + $item = RepurposeItem::factory()->for($repurpose)->create(); + $post = Post::factory()->create(['workspace_id' => $repurpose->workspace_id, 'repurpose_item_id' => $item->id]); + + DeleteRepurpose::execute($repurpose); + + expect(RepurposeItem::whereKey($item->id)->exists())->toBeFalse() + ->and(Post::whereKey($post->id)->exists())->toBeTrue() + ->and($post->fresh()->repurpose_item_id)->toBeNull(); +}); + +test('an active repurpose cannot be updated into a state it could not be activated in', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $repurpose = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'destinations' => [tiktokDestination($workspace)], + ]); + + expect(fn () => UpdateRepurpose::execute($repurpose, ['destinations' => []])) + ->toThrow(ValidationException::class); + + $switchedOff = SocialAccount::factory()->for($workspace)->create([ + 'platform' => Platform::Discord, + 'is_active' => false, + ]); + + expect(fn () => UpdateRepurpose::execute($repurpose, ['destinations' => [[ + 'social_account_id' => $switchedOff->id, + 'content_type' => ContentType::DiscordMessage->value, + 'meta' => ['channel_id' => '123'], + ]]]))->toThrow(ValidationException::class); +}); + +test('a repurpose can only be resumed from paused', function () { + $repurpose = Repurpose::factory()->disabled()->create(); + + expect(fn () => ResumeRepurpose::execute($repurpose))->toThrow(ValidationException::class); +}); + +test('a draft cannot be paused, so resuming can never start from a blank watermark', function () { + $repurpose = Repurpose::factory()->create(['status' => Status::Draft]); + + expect(fn () => PauseRepurpose::execute($repurpose))->toThrow(ValidationException::class); + + expect($repurpose->fresh()->status)->toBe(Status::Draft); +}); + +test('resuming stamps a watermark when the repurpose somehow lacks one', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'destinations' => [tiktokDestination($workspace)], + 'status' => Status::Paused, + 'activated_at' => null, + ]); + + $resumed = ResumeRepurpose::execute($repurpose); + + expect($resumed->status)->toBe(Status::Active) + ->and($resumed->activated_at)->not->toBeNull(); +}); + +test('an already active repurpose cannot be activated again over its watermark', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $repurpose = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'destinations' => [tiktokDestination($workspace)], + 'activated_at' => now()->subDays(5), + ]); + + expect(fn () => ActivateRepurpose::execute($repurpose))->toThrow(ValidationException::class); + + expect($repurpose->fresh()->activated_at->isSameDay(now()->subDays(5)))->toBeTrue(); +}); + +test('a draft cannot be turned off', function () { + $repurpose = Repurpose::factory()->create(['status' => Status::Draft]); + + expect(fn () => DisableRepurpose::execute($repurpose))->toThrow(ValidationException::class); + + expect($repurpose->fresh()->status)->toBe(Status::Draft); +}); + +test('an update the activation rules reject leaves the stored destinations untouched', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $destination = tiktokDestination($workspace); + + $repurpose = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'destinations' => [$destination], + ]); + + $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::PinterestVideoPin->value, + 'meta' => ['board_id' => 'b1'], + ]]]))->toThrow(ValidationException::class); + + expect($repurpose->fresh()->destinations)->toEqual([$destination]); +}); + +test('a transition reads the stored status, not the copy the caller is holding', function () { + $repurpose = Repurpose::factory()->active()->create(); + + Repurpose::query()->whereKey($repurpose->id)->update(['status' => Status::Paused]); + + expect($repurpose->status)->toBe(Status::Active) + ->and(fn () => PauseRepurpose::execute($repurpose))->toThrow(ValidationException::class); + + expect($repurpose->fresh()->status)->toBe(Status::Paused); +}); + +test('an update that repeats the current source and format leaves the watermark alone', function () { + [$workspace, $user, $account] = repurposeWorkspace(); + + $repurpose = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $account->id, + 'source_format' => SourceFormat::Reel, + 'destinations' => [tiktokDestination($workspace)], + 'activated_at' => now()->subDays(3), + ]); + + $watermark = $repurpose->activated_at; + + UpdateRepurpose::execute($repurpose, [ + 'source_social_account_id' => $repurpose->source_social_account_id, + 'source_format' => SourceFormat::Reel->value, + 'publish_mode' => PublishMode::Draft->value, + ]); + + $fresh = $repurpose->fresh(); + + expect($fresh->activated_at->equalTo($watermark))->toBeTrue() + ->and($fresh->publish_mode)->toBe(PublishMode::Draft); +}); diff --git a/tests/Feature/Repurpose/CaptionAdapterTest.php b/tests/Feature/Repurpose/CaptionAdapterTest.php new file mode 100644 index 000000000..01c360442 --- /dev/null +++ b/tests/Feature/Repurpose/CaptionAdapterTest.php @@ -0,0 +1,213 @@ +create(); + $caption = 'Short and sweet'; + + expect(app(CaptionAdapter::class)->adapt($workspace, null, $caption, Platform::TikTok)) + ->toBe($caption); +}); + +test('a caption that does not fit is truncated at a word boundary when ai is unavailable', function () { + $workspace = Workspace::factory()->create(); + $caption = str_repeat('palavra ', 2000); + + expect(Platform::TikTok->contentOverflow($caption))->toBeGreaterThan(0); + + $result = app(CaptionAdapter::class)->adapt($workspace, null, $caption, Platform::TikTok); + + expect(Platform::TikTok->contentOverflow($result))->toBe(0) + ->and($result)->not->toEndWith('palavr') + ->and($result)->toEndWith('palavra'); +}); + +test('truncation respects the tightest limit we support', function () { + $workspace = Workspace::factory()->create(); + $caption = 'A really long YouTube Short caption that keeps going well past one hundred characters so it has to be cut somewhere sensible.'; + + $result = app(CaptionAdapter::class)->adapt($workspace, null, $caption, Platform::YouTube); + + expect(Platform::YouTube->contentOverflow($result))->toBe(0) + ->and($result)->toStartWith('A really long YouTube Short caption'); +}); + +test('ai shortens the caption and the workspace is billed for it', 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]); + + $result = app(CaptionAdapter::class)->adapt($workspace, $user, str_repeat('palavra ', 2000), Platform::YouTube); + + expect($result)->toBe('A tight caption that fits.') + ->and(AiUsageLog::where('workspace_id', $workspace->id)->count())->toBe(1); +}); + +test('a shortened caption that still overflows falls back to truncation', function () { + config()->set('trypost.self_hosted', true); + PostContentShortener::fake([str_repeat('ainda enorme ', 200)]); + + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['account_id' => $user->account_id, 'user_id' => $user->id]); + + $result = app(CaptionAdapter::class)->adapt($workspace, $user, str_repeat('palavra ', 2000), Platform::YouTube); + + expect(Platform::YouTube->contentOverflow($result))->toBe(0) + ->and($result)->toStartWith('palavra'); +}); + +test('truncation targets the text the publisher sends, not the raw caption', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + $workspace = Workspace::factory()->create(); + $caption = str_repeat('a.b.c.d.e.f.g.h.com ', 40); + + $result = app(CaptionAdapter::class)->adapt($workspace, null, $caption, Platform::X); + $sent = app(ContentSanitizer::class)->displayText($result, Platform::X); + + expect(Platform::X->contentOverflow($sent))->toBe(0) + ->and(mb_strlen($sent))->toBeGreaterThan(200); +}); + +test('a caption keeps almost the whole allowance when nothing rewrites it', function () { + $workspace = Workspace::factory()->create(); + $caption = str_repeat('palavra ', 300); + + $result = app(CaptionAdapter::class)->adapt($workspace, null, $caption, Platform::TikTok); + + expect(Platform::TikTok->contentOverflow($result))->toBe(0) + ->and(mb_strlen($result))->toBeGreaterThan(Platform::TikTok->maxContentLength() - 10); +}); + +test('truncation keeps the line breaks the caption was written with', function () { + $workspace = Workspace::factory()->create(); + $caption = "Linha um\nLinha dois\n\n".str_repeat('palavra ', 300); + + $result = app(CaptionAdapter::class)->adapt($workspace, null, $caption, Platform::TikTok); + + expect(Platform::TikTok->contentOverflow($result))->toBe(0) + ->and($result)->toStartWith("Linha um\nLinha dois\n\n"); +}); + +test('a caption with no word boundary is cut hard rather than emptied', function () { + $workspace = Workspace::factory()->create(); + + $result = app(CaptionAdapter::class)->adapt($workspace, null, str_repeat('a', 500), Platform::YouTube); + + expect($result)->not->toBe('') + ->and(Platform::YouTube->contentOverflow($result))->toBe(0); +}); + +test('the shortener prompt leaves out brand context the workspace does not have', function () { + $bare = new PostContentShortener( + workspace: Workspace::factory()->make(['name' => '', 'brand_voice_traits' => []]), + platformLabel: 'YouTube Shorts', + limit: 100, + ); + + expect($bare->instructions()) + ->not->toContain('the brand ""') + ->not->toContain('Brand voice') + ->toContain('100 characters') + ->toContain('95 characters'); + + $branded = new PostContentShortener( + workspace: Workspace::factory()->make(['name' => 'Acme', 'brand_voice_traits' => ['casual']]), + platformLabel: 'TikTok', + limit: 2200, + ); + + expect($branded->instructions()) + ->toContain('the brand "Acme"') + ->toContain('Keep a casual, relaxed register.'); +}); + +test('a self-hosted install with no ai configured still gets a caption that fits', function () { + config()->set('trypost.self_hosted', true); + config()->set('ai.providers.openai.key', null); + config()->set('ai.providers.openai.url', 'http://127.0.0.1:9/v1'); + + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['account_id' => $user->account_id, 'user_id' => $user->id]); + + $result = app(CaptionAdapter::class)->adapt($workspace, $user, str_repeat('palavra ', 300), Platform::YouTube); + + 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); +}); + +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); +}); + +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/PollingTest.php b/tests/Feature/Repurpose/PollingTest.php new file mode 100644 index 000000000..5d4fd8185 --- /dev/null +++ b/tests/Feature/Repurpose/PollingTest.php @@ -0,0 +1,376 @@ + Http::response(['data' => $rows])]); +} + +function mediaRow(string $id = 'm1', string $productType = 'REELS', ?string $url = 'https://cdn.example.com/v.mp4'): array +{ + return array_filter([ + 'id' => $id, + 'media_type' => 'VIDEO', + 'media_product_type' => $productType, + 'media_url' => $url, + 'caption' => 'Hi', + 'permalink' => 'https://instagram.com/p/1', + 'timestamp' => '2026-09-04T10:00:00+0000', + ], fn ($value) => $value !== null); +} + +function instagramAccount(): SocialAccount +{ + $workspace = Workspace::factory()->create(); + + return SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); +} + +function activeRepurposeOn(SocialAccount $account, SourceFormat $format = SourceFormat::Reel): Repurpose +{ + return Repurpose::factory()->active()->create([ + 'workspace_id' => $account->workspace_id, + 'source_social_account_id' => $account->id, + 'source_format' => $format, + 'activated_at' => now()->subYear(), + ]); +} + +function poll(SocialAccount $account): void +{ + (new PollRepurposeSource($account))->handle(app(SourceFetcherFactory::class)); +} + +test('a new video of the watched format creates a pending item and dispatches processing', function () { + Bus::fake(); + fakeInstagramMedia([mediaRow()]); + + $account = instagramAccount(); + $repurpose = activeRepurposeOn($account); + + poll($account); + + $item = $repurpose->items()->sole(); + + expect($item->status)->toBe(ItemStatus::Pending) + ->and($item->source_media_id)->toBe('m1') + ->and($repurpose->fresh()->last_polled_at)->not->toBeNull() + ->and($repurpose->fresh()->next_poll_at)->not->toBeNull(); + + Bus::assertDispatched(ProcessRepurposeItem::class); +}); + +test('a format the repurpose does not watch is ignored entirely', function () { + Bus::fake(); + fakeInstagramMedia([mediaRow('feed-1', 'FEED')]); + + $account = instagramAccount(); + $repurpose = activeRepurposeOn($account, SourceFormat::Reel); + + poll($account); + + expect($repurpose->items()->count())->toBe(0); + + Bus::assertNotDispatched(ProcessRepurposeItem::class); +}); + +test('two repurposes on one account share a single round of calls', function () { + Bus::fake(); + fakeInstagramMedia([mediaRow('r1', 'REELS'), mediaRow('f1', 'FEED')]); + + $account = instagramAccount(); + $reels = activeRepurposeOn($account, SourceFormat::Reel); + $videos = activeRepurposeOn($account, SourceFormat::Video); + + poll($account); + + Http::assertSentCount(1); + + expect($reels->items()->sole()->source_media_id)->toBe('r1') + ->and($videos->items()->sole()->source_media_id)->toBe('f1'); + + Bus::assertDispatchedTimes(ProcessRepurposeItem::class, 2); +}); + +test('a video without a download url is skipped', function () { + Bus::fake(); + fakeInstagramMedia([mediaRow('m3', 'REELS', null)]); + + $account = instagramAccount(); + $repurpose = activeRepurposeOn($account); + + poll($account); + + expect($repurpose->items()->sole()->reason)->toBe(ItemReason::MediaUrlMissing); + + Bus::assertNotDispatched(ProcessRepurposeItem::class); +}); + +test('media already published through trypost is skipped', function () { + Bus::fake(); + fakeInstagramMedia([mediaRow('known-1')]); + + $account = instagramAccount(); + $repurpose = activeRepurposeOn($account); + + $post = Post::factory()->create(['workspace_id' => $account->workspace_id]); + PostPlatform::factory()->for($post)->create(['platform_post_id' => 'known-1']); + + poll($account); + + expect($repurpose->items()->sole()->reason)->toBe(ItemReason::PublishedViaTrypost); + + Bus::assertNotDispatched(ProcessRepurposeItem::class); +}); + +test('another workspace publishing the same media id does not skip ours', function () { + Bus::fake(); + fakeInstagramMedia([mediaRow('known-1')]); + + $account = instagramAccount(); + $repurpose = activeRepurposeOn($account); + + $post = Post::factory()->create(['workspace_id' => Workspace::factory()->create()->id]); + PostPlatform::factory()->for($post)->create(['platform_post_id' => 'known-1']); + + poll($account); + + expect($repurpose->items()->sole()->reason)->toBeNull(); + + Bus::assertDispatched(ProcessRepurposeItem::class); +}); + +test('media published before the watermark is ignored', function () { + Bus::fake(); + fakeInstagramMedia([mediaRow()]); + + $account = instagramAccount(); + $repurpose = activeRepurposeOn($account); + $repurpose->update(['activated_at' => now()]); + + poll($account); + + expect($repurpose->items()->count())->toBe(0); +}); + +test('polling twice logs the same media once', function () { + Bus::fake(); + fakeInstagramMedia([mediaRow()]); + + $account = instagramAccount(); + $repurpose = activeRepurposeOn($account); + + poll($account); + poll($account->fresh()); + + expect($repurpose->items()->count())->toBe(1); + + Bus::assertDispatchedTimes(ProcessRepurposeItem::class, 1); +}); + +test('an api error is recorded on every repurpose of the account', function () { + Bus::fake(); + Http::fake([config('trypost.platforms.instagram.graph_api').'/*' => Http::response(['error' => ['message' => 'Invalid token']], 401)]); + + $account = instagramAccount(); + $first = activeRepurposeOn($account, SourceFormat::Reel); + $second = activeRepurposeOn($account, SourceFormat::Video); + + poll($account); + + expect($first->fresh()->last_error)->toContain('Invalid token') + ->and($second->fresh()->last_error)->toContain('Invalid token') + ->and($first->items()->count())->toBe(0); +}); + +test('a rate limited source backs off instead of retrying next tick', function () { + Bus::fake(); + config()->set('trypost.repurpose.backoff_minutes', 60); + config()->set('trypost.repurpose.poll_interval_minutes', 15); + Http::fake([config('trypost.platforms.instagram.graph_api').'/*' => Http::response(['error' => ['code' => 4, 'message' => 'Application request limit reached']], 400)]); + + $account = instagramAccount(); + $repurpose = activeRepurposeOn($account); + + poll($account); + + $repurpose = $repurpose->fresh(); + + expect($repurpose->status)->toBe(Status::Active) + ->and(now()->diffInMinutes($repurpose->next_poll_at, absolute: true))->toBeGreaterThan(30); +}); + +test('a disconnected source is not polled', function () { + Bus::fake(); + Http::fake(); + + $account = instagramAccount(); + $repurpose = activeRepurposeOn($account); + $account->update(['disconnected_at' => now()]); + + poll($account->fresh()); + + Http::assertNothingSent(); + expect($repurpose->items()->count())->toBe(0); +}); + +test('the command dispatches one job per due account, not per repurpose', function () { + Bus::fake(); + + $account = instagramAccount(); + activeRepurposeOn($account, SourceFormat::Reel); + activeRepurposeOn($account, SourceFormat::Video); + + Repurpose::factory()->create(); + Repurpose::factory()->paused()->create(); + Repurpose::factory()->disabled()->create(); + + $this->artisan('repurpose:poll')->assertSuccessful(); + + Bus::assertDispatchedTimes(PollRepurposeSource::class, 1); +}); + +test('the command polls through a real dispatch, not just a faked bus', function () { + $account = instagramAccount(); + $repurpose = activeRepurposeOn($account, SourceFormat::Reel); + + fakeInstagramMedia([mediaRow()]); + + $this->artisan('repurpose:poll')->assertSuccessful(); + + expect($repurpose->fresh()->last_polled_at)->not->toBeNull() + ->and($repurpose->items()->count())->toBe(1); +}); + +test('an account that is not due yet is not dispatched', function () { + Bus::fake(); + + $account = instagramAccount(); + activeRepurposeOn($account)->update(['next_poll_at' => now()->addMinutes(10)]); + + $this->artisan('repurpose:poll')->assertSuccessful(); + + Bus::assertNotDispatched(PollRepurposeSource::class); +}); + +test('a business rate limit backs off even without the english wording', function () { + Bus::fake(); + config()->set('trypost.repurpose.backoff_minutes', 60); + config()->set('trypost.repurpose.poll_interval_minutes', 15); + + Http::fake([config('trypost.platforms.instagram.graph_api').'/*' => Http::response([ + 'error' => ['code' => 80002, 'message' => 'There have been too many calls from this Instagram Business Account'], + ], 400)]); + + $account = instagramAccount(); + $repurpose = activeRepurposeOn($account); + + poll($account); + + expect(now()->diffInMinutes($repurpose->fresh()->next_poll_at, absolute: true))->toBeGreaterThan(30); +}); + +test('a token echoed back by the source never lands in the stored error', function () { + Bus::fake(); + Http::fake([ + config('trypost.platforms.instagram.graph_api').'/*' => Http::response( + 'Invalid OAuth request: access_token=EAAG-super-secret', + 400, + ), + ]); + + $account = instagramAccount(); + $repurpose = activeRepurposeOn($account); + + poll($account); + + expect($repurpose->fresh()->last_error) + ->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(); + + expect($fresh->last_error)->toBe('Instagram rejected the request') + ->and($fresh->next_poll_at->isFuture())->toBeTrue(); +}); + +test('an orphaned repurpose is never dispatched for polling', function () { + Bus::fake(); + + $workspace = Workspace::factory()->create(); + $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + + $orphan = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $source->id, + ]); + + $healthy = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => SocialAccount::factory()->for($workspace)->create([ + 'platform' => Platform::Facebook, + ])->id, + ]); + + $orphan->update(['source_social_account_id' => null, 'status' => Status::Active]); + + Artisan::call('repurpose:poll'); + + Bus::assertDispatchedTimes(PollRepurposeSource::class, 1); + expect($healthy->fresh()->status)->toBe(Status::Active); +}); + +test('polling the same video twice queues it only once', function () { + Bus::fake(); + fakeInstagramMedia([mediaRow('m1')]); + + $workspace = Workspace::factory()->create(); + $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + + Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $source->id, + 'activated_at' => now()->subDays(30), + ]); + + (new PollRepurposeSource($source))->handle(app(SourceFetcherFactory::class)); + (new PollRepurposeSource($source))->handle(app(SourceFetcherFactory::class)); + + Bus::assertDispatchedTimes(ProcessRepurposeItem::class, 1); + expect(RepurposeItem::query()->count())->toBe(1); +}); diff --git a/tests/Feature/Repurpose/ProcessItemTest.php b/tests/Feature/Repurpose/ProcessItemTest.php new file mode 100644 index 000000000..51da823d6 --- /dev/null +++ b/tests/Feature/Repurpose/ProcessItemTest.php @@ -0,0 +1,527 @@ +set('trypost.allow_multiple_social_accounts', true); + + $workspace = Workspace::factory()->create(); + $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + $tiktok = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::TikTok]); + $youtube = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::YouTube]); + + $repurpose = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $source->id, + 'destinations' => [ + ['social_account_id' => $tiktok->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE']], + ['social_account_id' => $youtube->id, 'content_type' => ContentType::YouTubeShort->value, 'meta' => []], + ], + ]); + + return RepurposeItem::factory()->for($repurpose)->create(); +} + +function fakeVideoDownload(): void +{ + Http::fake([ + REPURPOSE_VIDEO_URL => fn () => Http::response( + file_get_contents(base_path('tests/fixtures/sample.mp4')), + 200, + ['Content-Type' => 'video/mp4'], + ), + ]); +} + +function processItem(RepurposeItem $item, string $caption = 'My caption'): void +{ + (new ProcessRepurposeItem($item, REPURPOSE_VIDEO_URL, $caption)) + ->handle(app(MediaAttacher::class), app(CaptionAdapter::class)); +} + +test('it creates one post per destination and publishes each', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + + processItem($item); + + $posts = Post::where('repurpose_item_id', $item->id)->get(); + + expect($item->fresh()->status)->toBe(ItemStatus::Published) + ->and($posts)->toHaveCount(2); + + foreach ($posts as $post) { + expect($post->created_via)->toBe(CreatedVia::Repurpose) + ->and($post->status)->toBe(PostStatus::Scheduled) + ->and($post->media)->toHaveCount(1) + ->and($post->postPlatforms()->enabled()->count())->toBe(1); + } + + expect(Post::query()->due()->whereIn('id', $posts->pluck('id'))->count())->toBe(2); + + Bus::assertNotDispatched(PublishPost::class); +}); + +test('the video is downloaded once and reused by every post', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + + processItem($item); + + Http::assertSentCount(1); + + $paths = Post::where('repurpose_item_id', $item->id)->get() + ->map(fn (Post $post) => data_get($post->media, '0.path')); + + expect($paths->filter())->toHaveCount(2) + ->and($paths->unique())->toHaveCount(1); +}); + +test('destination meta is carried onto the post platform', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + + processItem($item); + + $tiktokPlatform = PostPlatform::query() + ->enabled() + ->whereHas('post', fn ($query) => $query->where('repurpose_item_id', $item->id)) + ->where('platform', Platform::TikTok) + ->sole(); + + expect($tiktokPlatform->meta)->toEqual(['privacy_level' => 'PUBLIC_TO_EVERYONE']); +}); + +test('a caption over a destination limit is shortened for that post only', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + $long = str_repeat('palavra ', 400); + + processItem($item, $long); + + $captions = PostPlatform::query() + ->enabled() + ->whereHas('post', fn ($query) => $query->where('repurpose_item_id', $item->id)) + ->with('post') + ->get() + ->mapWithKeys(fn (PostPlatform $platform) => [$platform->platform->value => $platform->post->content]); + + expect(Platform::TikTok->contentOverflow($captions[Platform::TikTok->value]))->toBe(0) + ->and(Platform::YouTube->contentOverflow($captions[Platform::YouTube->value]))->toBe(0) + ->and(mb_strlen($captions[Platform::TikTok->value])) + ->toBeGreaterThan(mb_strlen($captions[Platform::YouTube->value])); +}); + +test('a failed download throws so the job retries, leaving no post behind', function () { + Bus::fake([PublishPost::class]); + Http::fake([REPURPOSE_VIDEO_URL => Http::response('', 404)]); + + $item = repurposeWithTwoDestinations(); + + expect(fn () => processItem($item))->toThrow(SourceDownloadException::class); + + expect($item->fresh()->reason)->toBeNull() + ->and($item->fresh()->status)->not->toBe(ItemStatus::Failed) + ->and(Post::where('repurpose_item_id', $item->id)->count())->toBe(0); + + Bus::assertNotDispatched(PublishPost::class); +}); + +test('a download that never recovers ends as failed once the tries run out', function () { + Bus::fake([PublishPost::class]); + Http::fake([REPURPOSE_VIDEO_URL => Http::response('', 404)]); + + $item = repurposeWithTwoDestinations(); + $job = new ProcessRepurposeItem($item, REPURPOSE_VIDEO_URL, 'My caption'); + + try { + $job->handle(app(MediaAttacher::class), app(CaptionAdapter::class)); + } catch (SourceDownloadException $exception) { + $job->failed($exception); + } + + expect($item->fresh()->status)->toBe(ItemStatus::Failed) + ->and($item->fresh()->reason)->toBe(ItemReason::DownloadFailed) + ->and($item->fresh()->error)->toContain('Could not download'); +}); + +test('a retried download that succeeds publishes normally', function () { + Bus::fake([PublishPost::class]); + Http::fake([ + REPURPOSE_VIDEO_URL => Http::sequence() + ->push('', 404) + ->push(file_get_contents(base_path('tests/fixtures/sample.mp4')), 200, ['Content-Type' => 'video/mp4']), + ]); + + $item = repurposeWithTwoDestinations(); + + expect(fn () => processItem($item))->toThrow(SourceDownloadException::class); + + processItem($item); + + expect($item->fresh()->status)->toBe(ItemStatus::Published) + ->and($item->fresh()->reason)->toBeNull() + ->and(Post::where('repurpose_item_id', $item->id)->count())->toBe(2); +}); + +test('running the job twice creates no extra posts', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + + processItem($item); + processItem($item->fresh()); + + expect(Post::where('repurpose_item_id', $item->id)->count())->toBe(2); +}); + +test('an interrupted attempt does not leave draft posts behind', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + + processItem($item); + + Post::where('repurpose_item_id', $item->id)->update(['status' => PostStatus::Draft]); + $item->update(['status' => ItemStatus::Processing]); + + processItem($item->fresh()); + + $posts = Post::where('repurpose_item_id', $item->id)->get(); + + expect($item->fresh()->status)->toBe(ItemStatus::Published) + ->and($posts)->toHaveCount(2) + ->and($posts->every(fn (Post $post) => $post->status === PostStatus::Scheduled))->toBeTrue(); +}); + +test('it still replicates when the repurpose creator is gone', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + $item->repurpose->update(['user_id' => null]); + + processItem($item->fresh()); + + expect($item->fresh()->status)->toBe(ItemStatus::Published) + ->and(Post::where('repurpose_item_id', $item->id)->count())->toBe(2); +}); + +test('a retry never destroys posts that are already publishing', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + + processItem($item); + + $ids = Post::where('repurpose_item_id', $item->id)->pluck('id'); + $item->update(['status' => ItemStatus::Processing]); + + processItem($item->fresh()); + + expect(Post::whereIn('id', $ids)->count())->toBe(2) + ->and(Post::where('repurpose_item_id', $item->id)->count())->toBe(2) + ->and($item->fresh()->status)->toBe(ItemStatus::Published); + + Bus::assertNotDispatched(PublishPost::class); +}); + +test('a caption survives characters the sanitizer would treat as markup', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + + processItem($item, 'Fiz isso com meu time <3 link na bio'); + + $post = Post::where('repurpose_item_id', $item->id)->first(); + + expect(app(ContentSanitizer::class)->sanitize($post->content, Platform::TikTok)) + ->toContain('link na bio'); +}); + +test('a destination switched off is skipped instead of publishing nowhere', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + $tiktokId = data_get($item->repurpose->destinations, '0.social_account_id'); + SocialAccount::whereKey($tiktokId)->update(['is_active' => false]); + + processItem($item->fresh()); + + $posts = Post::where('repurpose_item_id', $item->id)->get(); + + expect($posts)->toHaveCount(1) + ->and($posts->first()->postPlatforms()->enabled()->count())->toBe(1); +}); + +test('a destination pointing outside the workspace is skipped, never published to', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + $repurpose = $item->repurpose; + + $stranger = SocialAccount::factory() + ->for(Workspace::factory()->create()) + ->create(['platform' => Platform::TikTok]); + + $repurpose->update(['destinations' => [ + ...$repurpose->destinations, + ['social_account_id' => $stranger->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => []], + ]]); + + processItem($item); + + expect(Post::where('repurpose_item_id', $item->id)->count())->toBe(2); +}); + +test('the scheduler claims the repurposed posts, so nothing is dispatched twice', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + + processItem($item); + + Artisan::call('posts:process-scheduled'); + + Bus::assertDispatchedTimes(PublishPost::class, 2); + + Artisan::call('posts:process-scheduled'); + + Bus::assertDispatchedTimes(PublishPost::class, 2); +}); + +test('scheduling the posts announces the status change like any other post', function () { + Event::fake([PostStatusChanged::class]); + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + + processItem($item); + + Event::assertDispatchedTimes(PostStatusChanged::class, 2); +}); + +test('a repurpose set to draft creates the posts and stops there', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + $item->repurpose->update(['publish_mode' => PublishMode::Draft]); + + processItem($item->fresh()); + + $posts = Post::where('repurpose_item_id', $item->id)->get(); + + expect($posts)->toHaveCount(2) + ->and($item->fresh()->status)->toBe(ItemStatus::Drafted); + + foreach ($posts as $post) { + expect($post->status)->toBe(PostStatus::Draft) + ->and($post->scheduled_at)->toBeNull() + ->and($post->media)->toHaveCount(1); + } + + Artisan::call('posts:process-scheduled'); + + Bus::assertNotDispatched(PublishPost::class); +}); + +test('a draft run is not repeated when the job runs again', function () { + Bus::fake([PublishPost::class]); + fakeVideoDownload(); + + $item = repurposeWithTwoDestinations(); + $item->repurpose->update(['publish_mode' => PublishMode::Draft]); + + processItem($item->fresh()); + $item->update(['status' => ItemStatus::Processing]); + processItem($item->fresh()); + + expect(Post::where('repurpose_item_id', $item->id)->count())->toBe(2) + ->and($item->fresh()->status)->toBe(ItemStatus::Drafted); +}); + +test('an item with no usable destination records why', function () { + Storage::fake(); + + $workspace = Workspace::factory()->create(); + $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); + $off = SocialAccount::factory()->for($workspace)->create([ + 'platform' => Platform::TikTok, + 'is_active' => false, + ]); + + $repurpose = Repurpose::factory()->active()->create([ + 'workspace_id' => $workspace->id, + 'source_social_account_id' => $source->id, + 'destinations' => [ + ['social_account_id' => $off->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE']], + ], + ]); + + $item = RepurposeItem::factory()->for($repurpose)->create(); + + fakeVideoDownload(); + + (new ProcessRepurposeItem($item, REPURPOSE_VIDEO_URL, 'caption')) + ->handle(app(MediaAttacher::class), app(CaptionAdapter::class)); + + expect($item->fresh()->status)->toBe(ItemStatus::Failed) + ->and($item->fresh()->reason)->toBe(ItemReason::NoUsableDestinations); +}); + +test('an item already in flight still runs after its repurpose is paused', function () { + $item = repurposeWithTwoDestinations(); + $item->repurpose->update([ + 'publish_mode' => PublishMode::Draft, + 'status' => RepurposeStatus::Paused, + 'paused_reason' => PauseReason::SourceUnavailable, + ]); + + fakeVideoDownload(); + + (new ProcessRepurposeItem($item, REPURPOSE_VIDEO_URL, 'caption')) + ->handle(app(MediaAttacher::class), app(CaptionAdapter::class)); + + expect($item->fresh()->status)->toBe(ItemStatus::Drafted); +}); + +test('an exhausted publish-mode item leaves no orphan drafts behind', function () { + $item = repurposeWithTwoDestinations(); + + $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 but does not call the run a success', 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, + ]); + + (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::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); +}); + +test('the stored error never carries the signed source url', function () { + $item = repurposeWithTwoDestinations(); + + $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'); +}); + +test('a redelivered job does not replicate an item that was skipped', function () { + $item = repurposeWithTwoDestinations(); + fakeVideoDownload(); + + $item->update(['status' => ItemStatus::Skipped, 'reason' => ItemReason::PublishedViaTrypost]); + + (new ProcessRepurposeItem($item->fresh(), REPURPOSE_VIDEO_URL, 'caption')) + ->handle(app(MediaAttacher::class), app(CaptionAdapter::class)); + + expect(Post::query()->where('repurpose_item_id', $item->id)->count())->toBe(0) + ->and($item->fresh()->status)->toBe(ItemStatus::Skipped) + ->and($item->fresh()->reason)->toBe(ItemReason::PublishedViaTrypost); +}); diff --git a/tests/Feature/Repurpose/RepurposeModelTest.php b/tests/Feature/Repurpose/RepurposeModelTest.php new file mode 100644 index 000000000..bf16bb955 --- /dev/null +++ b/tests/Feature/Repurpose/RepurposeModelTest.php @@ -0,0 +1,113 @@ +create(); + $item = RepurposeItem::factory()->for($repurpose)->create(); + + expect($repurpose->status)->toBe(Status::Draft) + ->and($repurpose->workspace)->not->toBeNull() + ->and($repurpose->sourceAccount)->not->toBeNull() + ->and($repurpose->items->pluck('id')->all())->toBe([$item->id]) + ->and($item->status)->toBe(ItemStatus::Pending); +}); + +test('destinations round-trip as an array', function () { + $destinations = [ + ['social_account_id' => (string) Str::uuid(), 'content_type' => 'tiktok_video', 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE']], + ]; + + $repurpose = Repurpose::factory()->create(['destinations' => $destinations]); + + expect($repurpose->fresh()->destinations)->toEqual($destinations); +}); + +test('the same source media id cannot be logged twice for one repurpose', function () { + $repurpose = Repurpose::factory()->create(); + RepurposeItem::factory()->for($repurpose)->create(['source_media_id' => 'media-1']); + + expect(fn () => RepurposeItem::factory()->for($repurpose)->create(['source_media_id' => 'media-1'])) + ->toThrow(QueryException::class); +}); + +test('one source account can feed a repurpose per watched format', function () { + $repurpose = Repurpose::factory()->create(['source_format' => SourceFormat::Reel]); + + Repurpose::factory()->create([ + 'workspace_id' => $repurpose->workspace_id, + 'source_social_account_id' => $repurpose->source_social_account_id, + 'source_format' => SourceFormat::Story, + ]); + + expect(Repurpose::where('source_social_account_id', $repurpose->source_social_account_id)->count())->toBe(2); +}); + +test('a workspace can have one repurpose per connected account of the same network', function () { + config()->set('trypost.allow_multiple_social_accounts', true); + + $workspace = Workspace::factory()->create(); + $first = SocialAccount::factory()->for($workspace)->create(); + $second = SocialAccount::factory()->for($workspace)->create(); + + Repurpose::factory()->create(['workspace_id' => $workspace->id, 'source_social_account_id' => $first->id]); + Repurpose::factory()->create(['workspace_id' => $workspace->id, 'source_social_account_id' => $second->id]); + + expect(Repurpose::where('workspace_id', $workspace->id)->count())->toBe(2); +}); + +test('the database refuses a duplicate source and format for one workspace', function () { + $repurpose = Repurpose::factory()->create(['source_format' => SourceFormat::Reel]); + + expect(fn () => Repurpose::factory()->create([ + 'workspace_id' => $repurpose->workspace_id, + 'source_social_account_id' => $repurpose->source_social_account_id, + '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(); +}); diff --git a/tests/Feature/Repurpose/SourceFetcherTest.php b/tests/Feature/Repurpose/SourceFetcherTest.php new file mode 100644 index 000000000..51cbfe4d3 --- /dev/null +++ b/tests/Feature/Repurpose/SourceFetcherTest.php @@ -0,0 +1,315 @@ +for($account)->fetch($account, $since, $formats); +} + +test('instagram tags reels and feed videos apart by product type', function () { + Http::fake([ + instagramGraph().'/*/media*' => Http::response(['data' => [ + ['id' => 'r1', 'media_type' => 'VIDEO', 'media_product_type' => 'REELS', 'media_url' => 'https://cdn.example.com/r.mp4', 'caption' => 'Reel', 'permalink' => 'https://instagram.com/p/1', 'timestamp' => '2026-09-01T10:00:00+0000'], + ['id' => 'f1', 'media_type' => 'VIDEO', 'media_product_type' => 'FEED', 'media_url' => 'https://cdn.example.com/f.mp4', 'caption' => 'Feed', 'permalink' => 'https://instagram.com/p/2', 'timestamp' => '2026-09-01T11:00:00+0000'], + ['id' => 'i1', 'media_type' => 'IMAGE', 'media_product_type' => 'FEED', 'media_url' => 'https://cdn.example.com/i.jpg', 'caption' => 'Pic', 'timestamp' => '2026-09-01T12:00:00+0000'], + ]]), + ]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + + $media = fetchFor($account, [SourceFormat::Reel, SourceFormat::Video]); + + expect($media)->toHaveCount(3) + ->and($media[0]->format)->toBe(SourceFormat::Reel) + ->and($media[1]->format)->toBe(SourceFormat::Video) + ->and($media[2]->format)->toBeNull() + ->and($media[2]->format)->toBeNull(); +}); + +test('instagram only calls the stories edge when stories are watched', function () { + Http::fake([instagramGraph().'/*' => Http::response(['data' => []])]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + + fetchFor($account, [SourceFormat::Reel]); + + Http::assertSentCount(1); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/stories')); + + fetchFor($account, [SourceFormat::Story]); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/stories')); +}); + +test('an instagram account connected through facebook uses the facebook graph host', function () { + $graph = config('trypost.platforms.instagram-facebook.graph_api'); + Http::fake(["{$graph}/*" => Http::response(['data' => []])]); + + $account = SocialAccount::factory()->create(['platform' => Platform::InstagramFacebook]); + + fetchFor($account, [SourceFormat::Reel]); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), $graph)); +}); + +test('facebook reads reels and videos from their own edges and drops the overlap', 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::Reel, SourceFormat::Video]); + + expect($media)->toHaveCount(2) + ->and($media[0]->id)->toBe('v1') + ->and($media[0]->format)->toBe(SourceFormat::Reel) + ->and($media[1]->id)->toBe('v2') + ->and($media[1]->format)->toBe(SourceFormat::Video); +}); + +test('facebook stories resolve the downloadable file behind each story', function () { + Http::fake([ + facebookGraph().'/*/stories*' => Http::response(['data' => [ + ['post_id' => 's1', 'status' => 'PUBLISHED', 'media_type' => 'video', 'media_id' => 'vid-1', 'url' => 'https://facebook.com/stories/1', 'creation_time' => '2026-09-03T10:00:00+0000'], + ['post_id' => 's2', 'status' => 'PUBLISHED', 'media_type' => 'photo', 'media_id' => 'pic-1', 'url' => 'https://facebook.com/stories/2', 'creation_time' => '2026-09-03T11:00:00+0000'], + ]]), + facebookGraph().'/vid-1*' => Http::response(['source' => 'https://cdn.example.com/story.mp4']), + ]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Facebook]); + + $media = fetchFor($account, [SourceFormat::Story]); + + expect($media)->toHaveCount(1) + ->and($media[0]->id)->toBe('s1') + ->and($media[0]->format)->toBe(SourceFormat::Story) + ->and($media[0]->downloadUrl)->toBe('https://cdn.example.com/story.mp4'); +}); + +test('a since timestamp is sent to the api', function () { + Http::fake([instagramGraph().'/*' => Http::response(['data' => []])]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + $since = now()->subDay(); + + fetchFor($account, [SourceFormat::Reel], $since); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'since='.$since->getTimestamp())); +}); + +test('a failed response throws so the caller can record it', function () { + Http::fake([instagramGraph().'/*' => Http::response(['error' => ['message' => 'Invalid token']], 401)]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + + expect(fn () => fetchFor($account, [SourceFormat::Reel])) + ->toThrow(RuntimeException::class, 'Invalid token'); +}); + +test('an unsupported platform cannot be a source', function () { + $account = SocialAccount::factory()->create(['platform' => Platform::TikTok]); + + expect(fn () => app(SourceFetcherFactory::class)->for($account)) + ->toThrow(InvalidArgumentException::class); +}); + +test('a standalone instagram video with no product type still counts as a reel', function () { + Http::fake([ + config('trypost.platforms.instagram.graph_api').'/*' => Http::response(['data' => [ + ['id' => '1', 'media_type' => 'VIDEO', 'media_url' => 'https://cdn/v.mp4', 'caption' => 'Hi'], + ['id' => '2', 'media_type' => 'IMAGE', 'media_url' => 'https://cdn/i.jpg'], + ]]), + ]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + + $media = app(SourceFetcherFactory::class)->for($account)->fetch($account, null, [SourceFormat::Reel]); + + expect($media)->toHaveCount(2) + ->and($media[0]->format)->toBe(SourceFormat::Reel) + ->and($media[1]->format)->toBeNull(); +}); + +test('a story is a story because of the edge it came from, not a field', function () { + Http::fake([ + config('trypost.platforms.instagram.graph_api').'/*/media*' => Http::response(['data' => []]), + config('trypost.platforms.instagram.graph_api').'/*/stories*' => Http::response(['data' => [ + ['id' => 's1', 'media_type' => 'VIDEO', 'media_url' => 'https://cdn/s.mp4'], + ]]), + ]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + + $media = app(SourceFetcherFactory::class)->for($account)->fetch($account, null, [SourceFormat::Story]); + + expect($media)->toHaveCount(1) + ->and($media[0]->format)->toBe(SourceFormat::Story); +}); + +test('a field the token cannot read costs the caption, not the whole source', function () { + $responses = [ + Http::response(['error' => ['code' => 100, 'message' => '(#100) Tried accessing nonexistent field (caption)']], 400), + Http::response(['data' => [ + ['id' => '1', 'media_type' => 'VIDEO', 'media_url' => 'https://cdn/v.mp4', 'permalink' => 'https://instagram.com/p/1'], + ]]), + ]; + + Http::fake([ + config('trypost.platforms.instagram.graph_api').'/*' => function () use (&$responses) { + return array_shift($responses); + }, + ]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + + $media = app(SourceFetcherFactory::class)->for($account)->fetch($account, null, [SourceFormat::Reel]); + + expect($media)->toHaveCount(1) + ->and($media[0]->format)->toBe(SourceFormat::Reel) + ->and($media[0]->downloadUrl)->toBe('https://cdn/v.mp4') + ->and($media[0]->caption)->toBe(''); + + Http::assertSent(fn ($request) => str_contains((string) $request->url(), 'media_product_type')); + Http::assertSent(fn ($request) => ! str_contains((string) $request->url(), 'media_product_type') + && str_contains((string) $request->url(), 'media_type')); +}); + +test('an error that is not about fields is not retried', function () { + Http::fake([ + config('trypost.platforms.instagram.graph_api').'/*' => Http::response( + ['error' => ['code' => 190, 'message' => 'Invalid OAuth access token']], + 401, + ), + ]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + + expect(fn () => app(SourceFetcherFactory::class)->for($account)->fetch($account, null, [SourceFormat::Reel])) + ->toThrow(SourceFetchException::class); + + Http::assertSentCount(1); +}); + +test('facebook only resolves the file for a published video story', function () { + Http::fake([ + config('trypost.platforms.facebook.graph_api').'/*/stories*' => Http::response(['data' => [ + ['post_id' => 'p1', 'status' => 'PUBLISHED', 'media_type' => 'video', 'media_id' => 'v1', 'url' => 'https://fb/1'], + ['post_id' => 'p2', 'status' => 'PUBLISHED', 'media_type' => 'photo', 'media_id' => 'ph1', 'url' => 'https://fb/2'], + ['post_id' => 'p3', 'status' => 'ARCHIVED', 'media_type' => 'video', 'media_id' => 'v2', 'url' => 'https://fb/3'], + ]]), + config('trypost.platforms.facebook.graph_api').'/v1*' => Http::response(['source' => 'https://cdn/v1.mp4']), + ]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Facebook]); + + $media = app(SourceFetcherFactory::class)->for($account)->fetch($account, null, [SourceFormat::Story]); + + expect($media)->toHaveCount(1) + ->and($media[0]->id)->toBe('p1') + ->and($media[0]->downloadUrl)->toBe('https://cdn/v1.mp4'); + + Http::assertSentCount(2); +}); + +test('the story listing is bounded and filtered by the watermark', function () { + Http::fake([config('trypost.platforms.facebook.graph_api').'/*' => Http::response(['data' => []])]); + + $account = SocialAccount::factory()->create(['platform' => Platform::Facebook]); + $since = now()->subDay(); + + app(SourceFetcherFactory::class)->for($account)->fetch($account, $since, [SourceFormat::Story]); + + 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++; + + 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') + ->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); +}); diff --git a/tests/Feature/Repurpose/SourceInvariantsTest.php b/tests/Feature/Repurpose/SourceInvariantsTest.php new file mode 100644 index 000000000..8558304a1 --- /dev/null +++ b/tests/Feature/Repurpose/SourceInvariantsTest.php @@ -0,0 +1,197 @@ +set('trypost.allow_multiple_social_accounts', true); + + ['plain_token' => $token, 'workspace' => $this->workspace] = createApiTestToken(); + + $this->user = $this->workspace->owner; + $this->headers = ['Authorization' => "Bearer {$token}", 'Accept' => 'application/json']; + + $this->source = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::Instagram]); + $this->other = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::Instagram]); +}); + +function selfDestination(SocialAccount $account): array +{ + return [ + 'social_account_id' => $account->id, + 'content_type' => ContentType::InstagramReel->value, + 'meta' => [], + ]; +} + +test('the source cannot be a destination of itself on any surface', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $payload = ['destinations' => [selfDestination($this->source)]]; + + $this->actingAs($this->user) + ->put(route('app.repurposes.update', $repurpose), $payload) + ->assertSessionHasErrors(['destinations.0.social_account_id' => __('repurposes.errors.destination_is_source')]); + + $this->withHeaders($this->headers) + ->putJson(route('api.repurposes.update', $repurpose), $payload) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJsonValidationErrors(['destinations.0.social_account_id']); + + $this->withHeaders($this->headers) + ->postJson(route('api.repurposes.store'), [ + 'source_social_account_id' => $this->other->id, + 'destinations' => [selfDestination($this->other)], + ]) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + + TryPostServer::actingAs($this->user) + ->tool(UpdateRepurposeTool::class, ['repurpose_id' => $repurpose->id, ...$payload]) + ->assertHasErrors(); + + TryPostServer::actingAs($this->user) + ->tool(CreateRepurposeTool::class, [ + 'source_social_account_id' => $this->other->id, + 'destinations' => [selfDestination($this->other)], + ]) + ->assertHasErrors(); + + expect($repurpose->fresh()->destinations)->toBe([]); +}); + +test('a source and format already watched is refused on any surface', function () { + Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->other->id, + 'source_format' => SourceFormat::Reel, + ]); + + $mine = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'source_format' => SourceFormat::Reel, + ]); + + $payload = ['source_social_account_id' => $this->other->id]; + + $this->actingAs($this->user) + ->put(route('app.repurposes.update', $mine), $payload) + ->assertSessionHasErrors(['source_social_account_id' => __('repurposes.errors.source_already_used')]); + + $this->withHeaders($this->headers) + ->putJson(route('api.repurposes.update', $mine), $payload) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJsonValidationErrors(['source_social_account_id']); + + $this->withHeaders($this->headers) + ->postJson(route('api.repurposes.store'), $payload) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + + TryPostServer::actingAs($this->user) + ->tool(UpdateRepurposeTool::class, ['repurpose_id' => $mine->id, ...$payload]) + ->assertHasErrors(); + + TryPostServer::actingAs($this->user) + ->tool(CreateRepurposeTool::class, $payload) + ->assertHasErrors(); + + expect($mine->fresh()->source_social_account_id)->toBe($this->source->id); +}); + +test('keeping the same source on an update is not read as a clash with itself', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'source_format' => SourceFormat::Reel, + ]); + + $this->actingAs($this->user) + ->put(route('app.repurposes.update', $repurpose), [ + 'source_social_account_id' => $this->source->id, + 'source_format' => SourceFormat::Reel->value, + ]) + ->assertSessionHasNoErrors(); +}); + +test('a race past the validation still reads as a message, never as a constraint', function () { + Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->other->id, + 'source_format' => SourceFormat::Reel, + ]); + + $mine = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'source_format' => SourceFormat::Reel, + ]); + + expect(fn () => UpdateRepurpose::execute($mine, ['source_social_account_id' => $this->other->id])) + ->toThrow(ValidationException::class, __('repurposes.errors.source_already_used')); + + expect(fn () => CreateRepurpose::execute($this->workspace, $this->user, [ + 'source_social_account_id' => $this->other->id, + 'source_format' => SourceFormat::Reel->value, + ]))->toThrow(ValidationException::class, __('repurposes.errors.source_already_used')); +}); + +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, + ]); + + expect(fn () => SourceIsFree::assert($this->workspace->id, $this->other->id, SourceFormat::Reel)) + ->toThrow(ValidationException::class); + + SourceIsFree::assert($this->workspace->id, $this->other->id, SourceFormat::Story); + + expect(true)->toBeTrue(); +}); + +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, + ]); + + SourceIsFree::assert($this->workspace->id, $this->other->id, SourceFormat::Reel, $mine->id); + + expect(true)->toBeTrue(); +}); + +test('the helper itself refuses the source as its own destination', function () { + $destinations = [ + ['social_account_id' => $this->other->id], + ['social_account_id' => $this->source->id], + ]; + + expect(fn () => SourceIsNotADestination::assert($destinations, $this->source->id)) + ->toThrow(ValidationException::class); + + SourceIsNotADestination::assert( + [['social_account_id' => $this->other->id]], + $this->source->id, + ); + + expect(true)->toBeTrue(); +}); diff --git a/tests/Feature/Repurpose/TranslationKeysTest.php b/tests/Feature/Repurpose/TranslationKeysTest.php new file mode 100644 index 000000000..e57b8ec8f --- /dev/null +++ b/tests/Feature/Repurpose/TranslationKeysTest.php @@ -0,0 +1,39 @@ + array_map( + fn (string $path): string => basename($path), + array_filter(glob(dirname(__DIR__, 3).'/lang/*'), 'is_dir'), +)); + +test('every enum value the interface interpolates has a string', function (string $locale) { + $strings = repurposeStrings($locale); + + foreach (Status::cases() as $status) { + expect(data_get($strings, "status.{$status->value}"))->not->toBeNull() + ->and(data_get($strings, "status_card.{$status->value}_hint"))->not->toBeNull(); + } + + foreach (ItemStatus::cases() as $status) { + expect(data_get($strings, "items.statuses.{$status->value}"))->not->toBeNull(); + } + + foreach (ItemReason::cases() as $reason) { + expect(data_get($strings, "items.reasons.{$reason->value}"))->not->toBeNull(); + } + + foreach (SourceFormat::cases() as $format) { + expect(data_get($strings, "formats.{$format->value}"))->not->toBeNull(); + } +})->with('locales'); diff --git a/tests/Feature/Repurpose/WebTest.php b/tests/Feature/Repurpose/WebTest.php new file mode 100644 index 000000000..05ca96203 --- /dev/null +++ b/tests/Feature/Repurpose/WebTest.php @@ -0,0 +1,596 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + $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 destinationPayload(SocialAccount $account): array +{ + return [ + 'social_account_id' => $account->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + ]; +} + +test('the index lists the workspace repurposes', function () { + Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $this->actingAs($this->user) + ->get(route('app.repurposes.index')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('repurposes/Index') + ->has('repurposes.data', 1) + ->where('repurposes.data.0.source_account.id', $this->source->id) + ->has('sourceAccounts', 1)); +}); + +test('only networks we can download from are offered as a source', function () { + $this->actingAs($this->user) + ->get(route('app.repurposes.index')) + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('sourceAccounts', 1) + ->where('sourceAccounts.0.id', $this->source->id)); +}); + +test('storing creates a draft and redirects to its page', function () { + $response = $this->actingAs($this->user) + ->post(route('app.repurposes.store'), ['source_social_account_id' => $this->source->id]); + + $repurpose = Repurpose::sole(); + + $response->assertRedirect(route('app.repurposes.show', $repurpose)); + + expect($repurpose->status)->toBe(Status::Draft); +}); + +test('storing for an account that already has a repurpose redirects to the existing one', function () { + $existing = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $this->actingAs($this->user) + ->post(route('app.repurposes.store'), ['source_social_account_id' => $this->source->id]) + ->assertRedirect(route('app.repurposes.show', $existing)); + + expect(Repurpose::count())->toBe(1); +}); + +test('the show page renders the repurpose, its destinations and its items', 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)) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('repurposes/Show') + ->where('repurpose.id', $repurpose->id) + ->has('destinationAccounts', 2) + ->has('items')); +}); + +test('updating saves destinations with their platform meta', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $destination = destinationPayload($this->tiktok); + + $this->actingAs($this->user) + ->put(route('app.repurposes.update', $repurpose), ['destinations' => [$destination]]) + ->assertRedirect(); + + expect($repurpose->fresh()->destinations)->toEqual([$destination]); +}); + +test('the status transitions are exposed', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'destinations' => [destinationPayload($this->tiktok)], + ]); + + $this->actingAs($this->user)->post(route('app.repurposes.activate', $repurpose))->assertRedirect(); + expect($repurpose->fresh()->status)->toBe(Status::Active); + + $this->actingAs($this->user)->post(route('app.repurposes.pause', $repurpose))->assertRedirect(); + expect($repurpose->fresh()->status)->toBe(Status::Paused); + + $this->actingAs($this->user)->post(route('app.repurposes.resume', $repurpose))->assertRedirect(); + expect($repurpose->fresh()->status)->toBe(Status::Active); + + $this->actingAs($this->user)->post(route('app.repurposes.disable', $repurpose))->assertRedirect(); + expect($repurpose->fresh()->status)->toBe(Status::Disabled); +}); + +test('activating without a destination fails validation', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $this->actingAs($this->user) + ->post(route('app.repurposes.activate', $repurpose)) + ->assertSessionHasErrors('destinations'); +}); + +test('deleting removes the repurpose', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $this->actingAs($this->user) + ->delete(route('app.repurposes.destroy', $repurpose)) + ->assertRedirect(route('app.repurposes.index')); + + expect(Repurpose::count())->toBe(0); +}); + +test('a viewer cannot create a repurpose', function () { + $viewer = User::factory()->create([ + 'account_id' => $this->user->account_id, + 'current_workspace_id' => $this->workspace->id, + ]); + $this->workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]); + + $this->actingAs($viewer) + ->post(route('app.repurposes.store'), ['source_social_account_id' => $this->source->id]) + ->assertForbidden(); +}); + +test('a repurpose from another workspace is not reachable', function () { + $stranger = Repurpose::factory()->create(); + + $this->actingAs($this->user) + ->get(route('app.repurposes.show', $stranger)) + ->assertForbidden(); +}); + +test('an account from another workspace cannot become a source', function () { + $stranger = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + + $this->actingAs($this->user) + ->post(route('app.repurposes.store'), ['source_social_account_id' => $stranger->id]) + ->assertSessionHasErrors('source_social_account_id'); + + expect(Repurpose::count())->toBe(0); +}); + +test('an account from another workspace cannot become a destination', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $stranger = SocialAccount::factory()->create(['platform' => Platform::TikTok]); + + $this->actingAs($this->user) + ->put(route('app.repurposes.update', $repurpose), [ + 'destinations' => [[ + 'social_account_id' => $stranger->id, + 'content_type' => ContentType::TikTokVideo->value, + ]], + ]) + ->assertSessionHasErrors('destinations.0.social_account_id'); + + expect($repurpose->fresh()->destinations)->toBe([]); +}); + +test('a switched-off account is accepted as a destination and skipped at publish time', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $this->tiktok->update(['is_active' => false]); + + $this->actingAs($this->user) + ->put(route('app.repurposes.update', $repurpose), [ + 'destinations' => [destinationPayload($this->tiktok)], + ]) + ->assertSessionHasNoErrors(); + + expect($repurpose->fresh()->destinations)->toHaveCount(1); +}); + +test('the source account token never reaches the page', function () { + $this->source->update(['meta' => ['user_token' => 'EAAG-secret-token', 'page_id' => '1']]); + + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + foreach ([route('app.repurposes.index'), route('app.repurposes.show', $repurpose)] as $url) { + $this->actingAs($this->user)->get($url)->assertOk()->assertDontSee('EAAG-secret-token'); + } +}); + +test('an account from another workspace cannot be set as the source on update', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $stranger = SocialAccount::factory()->create(['platform' => Platform::Instagram]); + + $this->actingAs($this->user) + ->put(route('app.repurposes.update', $repurpose), ['source_social_account_id' => $stranger->id]) + ->assertSessionHasErrors('source_social_account_id'); + + expect($repurpose->fresh()->source_social_account_id)->toBe($this->source->id); +}); + +test('destination meta errors read as friendly names, not raw array paths', 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, + ]); + + $this->actingAs($this->user) + ->from(route('app.repurposes.show', $repurpose)) + ->put(route('app.repurposes.update', $repurpose), [ + 'source_social_account_id' => $this->source->id, + 'destinations' => [[ + 'social_account_id' => $pinterest->id, + 'content_type' => ContentType::PinterestPin->value, + 'meta' => ['board_id' => 'board-1', 'title' => str_repeat('a', 101), 'link' => 'not-a-url'], + ]], + ]) + ->assertSessionHasErrors([ + 'destinations.0.meta.title' => __('posts.form.pinterest.title_max'), + 'destinations.0.meta.link' => __('posts.form.pinterest.link_invalid'), + ]); +}); + +test('the destination settings props load once and stay out of scroll pages', function () { + Http::fake(['*' => Http::response(['data' => []])]); + + $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('platformConfigs') + ->has('pinterestBoards') + ->has('tiktokCreatorInfos')); + + $partial = $this->actingAs($this->user) + ->withHeaders([ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => Inertia::getVersion(), + 'X-Inertia-Partial-Component' => 'repurposes/Show', + 'X-Inertia-Partial-Data' => 'items', + ]) + ->get(route('app.repurposes.show', $repurpose)) + ->assertOk(); + + expect($partial->json('props'))->toHaveKey('items') + ->not->toHaveKey('platformConfigs') + ->not->toHaveKey('pinterestBoards') + ->not->toHaveKey('tiktokCreatorInfos'); +}); + +test('a destination whose account was switched off is kept, not rejected', function () { + $destination = destinationPayload($this->tiktok); + + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'destinations' => [$destination], + ]); + + $this->tiktok->update(['is_active' => false]); + + $this->actingAs($this->user) + ->put(route('app.repurposes.update', $repurpose), [ + 'source_social_account_id' => $this->source->id, + 'destinations' => [$destination], + ]) + ->assertSessionHasNoErrors(); + + expect($repurpose->fresh()->destinations)->toHaveCount(1); + + $this->actingAs($this->user) + ->put(route('app.repurposes.update', $repurpose), [ + 'source_social_account_id' => $this->source->id, + 'destinations' => [], + ]) + ->assertSessionHasNoErrors(); + + expect($repurpose->fresh()->destinations)->toBe([]); +}); + +test('the index scrolls instead of loading every repurpose at once', function () { + config()->set('app.pagination.default', 1); + + $second = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::Facebook]); + + Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'created_at' => now()->subHour(), + ]); + + Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $second->id, + 'created_at' => now(), + ]); + + $this->actingAs($this->user) + ->get(route('app.repurposes.index')) + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('repurposes.data', 1) + ->where('repurposes.meta.per_page', 1) + ->where('repurposes.meta.total', 2)); +}); + +test('every per-platform meta key the web surface accepts is stored, not stripped', function () { + $discord = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::Discord]); + + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $meta = [ + 'channel_id' => '9001', + 'channel_name' => 'reels', + 'embeds' => [['title' => 'Watch', 'url' => 'https://trypost.it', 'color' => '#ff8800']], + ]; + + $this->actingAs($this->user) + ->put(route('app.repurposes.update', $repurpose), [ + 'source_social_account_id' => $this->source->id, + 'destinations' => [[ + 'social_account_id' => $discord->id, + 'content_type' => ContentType::DiscordMessage->value, + 'meta' => $meta, + ]], + ]) + ->assertSessionHasNoErrors(); + + expect(data_get($repurpose->fresh()->destinations, '0.meta'))->toEqual($meta); +}); + +test('each destination is told which content type to start on', function () { + $youtube = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::YouTube]); + + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'source_format' => SourceFormat::Reel, + ]); + + $this->actingAs($this->user) + ->get(route('app.repurposes.show', $repurpose)) + ->assertInertia(fn (AssertableInertia $page) => $page + ->where("recommendedFormats.{$this->tiktok->id}", ContentType::TikTokVideo->value) + ->where("recommendedFormats.{$youtube->id}", ContentType::YouTubeShort->value)); +}); + +test('the publishing mode is offered on the page and saved', 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('publishModes', 2) + ->where('publishModes.0.value', PublishMode::Publish->value) + ->where('repurpose.publish_mode', PublishMode::Publish->value)); + + $this->actingAs($this->user) + ->put(route('app.repurposes.update', $repurpose), [ + 'source_social_account_id' => $this->source->id, + 'publish_mode' => PublishMode::Draft->value, + 'destinations' => [destinationPayload($this->tiktok)], + ]) + ->assertSessionHasNoErrors(); + + 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)); +}); + +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); + }); +}); + +test('activity is ordered by when the original was posted, which is what it shows', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $older = RepurposeItem::factory()->for($repurpose)->create([ + 'source_created_at' => now()->subDays(2), + 'created_at' => now(), + ]); + + $newer = RepurposeItem::factory()->for($repurpose)->create([ + 'source_created_at' => now()->subHour(), + 'created_at' => now()->subDay(), + ]); + + $this->actingAs($this->user) + ->get(route('app.repurposes.show', $repurpose)) + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('items.data.0.id', $newer->id) + ->where('items.data.1.id', $older->id)); +}); + +test('the activity item carries both the moment we acted and the original date', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + ]); + + $item = RepurposeItem::factory()->for($repurpose)->create([ + 'source_created_at' => now()->subDays(5), + 'created_at' => now()->subMinutes(20), + ]); + + $this->actingAs($this->user) + ->get(route('app.repurposes.show', $repurpose)) + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('items.data.0.id', $item->id) + ->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', fn (Collection $posts): bool => $posts + ->pluck('platforms.0.status') + ->sort() + ->values() + ->all() === [PostPlatformStatus::Failed->value, PostPlatformStatus::Published->value])); +}); + +test('a switched-off destination is still sent to the page so editing cannot drop it', function () { + $repurpose = Repurpose::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'source_social_account_id' => $this->source->id, + 'destinations' => [destinationPayload($this->tiktok)], + ]); + + $this->tiktok->update(['is_active' => false]); + + $this->actingAs($this->user) + ->get(route('app.repurposes.show', $repurpose)) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('destinationAccounts', 2) + ->where('repurpose.destinations.0.social_account_id', $this->tiktok->id)); +}); diff --git a/tests/Feature/Services/Social/YouTubePublisherTest.php b/tests/Feature/Services/Social/YouTubePublisherTest.php index ca8f33729..eb7a072b2 100644 --- a/tests/Feature/Services/Social/YouTubePublisherTest.php +++ b/tests/Feature/Services/Social/YouTubePublisherTest.php @@ -186,7 +186,7 @@ // Long content: truncates to leave room for #Shorts tag (100 chars max) $longContent = str_repeat('A', 200); $title = $method->invoke($publisher, $longContent); - expect(strlen($title))->toBeLessThanOrEqual(100); + expect(mb_strlen($title))->toBeLessThanOrEqual(100); expect($title)->toEndWith(' #Shorts'); // Multi-line content: only uses first line before period @@ -199,3 +199,24 @@ $title = $method->invoke($publisher, $newlineContent); expect($title)->toBe('Title line #Shorts'); }); + +test('youtube publisher counts an accented title in characters, not bytes', function () { + $reflection = new ReflectionClass(YouTubePublisher::class); + $method = $reflection->getMethod('buildTitle'); + $method->setAccessible(true); + + $publisher = new YouTubePublisher; + + foreach (range(0, 11) as $pad) { + $title = $method->invoke($publisher, str_repeat('a', $pad).str_repeat('ação ', 30)); + + expect(mb_check_encoding($title, 'UTF-8'))->toBeTrue() + ->and(mb_strlen($title))->toBeLessThanOrEqual(100); + } + + $accented = str_repeat('ção', 30); + + expect(mb_strlen($accented))->toBe(90) + ->and(strlen($accented))->toBeGreaterThan(92) + ->and($method->invoke($publisher, $accented))->toBe($accented.' #Shorts'); +}); 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 () { diff --git a/tests/Unit/Policies/RepurposePolicyTest.php b/tests/Unit/Policies/RepurposePolicyTest.php new file mode 100644 index 000000000..c6fa78575 --- /dev/null +++ b/tests/Unit/Policies/RepurposePolicyTest.php @@ -0,0 +1,53 @@ +account = Account::factory()->create(); + $this->owner = User::factory()->create(['account_id' => $this->account->id]); + $this->account->update(['owner_id' => $this->owner->id]); + + $this->workspace = Workspace::factory()->create([ + 'account_id' => $this->account->id, + 'user_id' => $this->owner->id, + ]); + $this->owner->update(['current_workspace_id' => $this->workspace->id]); + + $this->member = User::factory()->create(['account_id' => $this->account->id, 'current_workspace_id' => $this->workspace->id]); + $this->viewer = User::factory()->create(['account_id' => $this->account->id, 'current_workspace_id' => $this->workspace->id]); + + $this->workspace->members()->attach($this->member->id, ['role' => Role::Member->value]); + $this->workspace->members()->attach($this->viewer->id, ['role' => Role::Viewer->value]); + + $this->repurpose = Repurpose::factory()->create(['workspace_id' => $this->workspace->id]); +}); + +test('an owner and a member can manage repurposes', function () { + foreach ([$this->owner, $this->member] as $user) { + expect($user->can('viewAny', Repurpose::class))->toBeTrue() + ->and($user->can('create', Repurpose::class))->toBeTrue() + ->and($user->can('update', $this->repurpose))->toBeTrue() + ->and($user->can('delete', $this->repurpose))->toBeTrue(); + } +}); + +test('a viewer cannot manage repurposes', function () { + expect($this->viewer->can('create', Repurpose::class))->toBeFalse() + ->and($this->viewer->can('update', $this->repurpose))->toBeFalse() + ->and($this->viewer->can('delete', $this->repurpose))->toBeFalse(); +}); + +test('a repurpose from another workspace is invisible', function () { + $stranger = User::factory()->create(); + $strangerWorkspace = Workspace::factory()->create(['user_id' => $stranger->id]); + $stranger->update(['current_workspace_id' => $strangerWorkspace->id]); + + expect($stranger->can('view', $this->repurpose))->toBeFalse() + ->and($stranger->can('update', $this->repurpose))->toBeFalse(); +}); diff --git a/tests/fixtures/sample.mp4 b/tests/fixtures/sample.mp4 new file mode 100644 index 000000000..918f1dfb0 Binary files /dev/null and b/tests/fixtures/sample.mp4 differ