diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index a0f439e89..5f8fe8097 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -5,6 +5,7 @@ namespace App\Actions\Post; use App\Enums\Post\CreatedVia; +use App\Enums\Post\PublishMode; use App\Enums\Post\Status as PostStatus; use App\Models\Post; use App\Models\User; @@ -34,6 +35,7 @@ class CreatePost * media?: array, * date?: ?string, * scheduled_at?: ?string, + * publish_mode?: PublishMode|string|null, * created_via?: ?CreatedVia, * platforms?: array}>, * label_ids?: array @@ -49,6 +51,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P 'content' => data_get($data, 'content', ''), 'media' => data_get($data, 'media', []), 'status' => PostStatus::Draft, + 'publish_mode' => data_get($data, 'publish_mode', PublishMode::Auto), 'created_via' => data_get($data, 'created_via'), 'scheduled_at' => $scheduledAt, ]); diff --git a/app/Actions/Post/UpdatePost.php b/app/Actions/Post/UpdatePost.php index 7bb2fac1e..8e50b47bb 100644 --- a/app/Actions/Post/UpdatePost.php +++ b/app/Actions/Post/UpdatePost.php @@ -36,6 +36,7 @@ public static function execute(Workspace $workspace, Post $post, array $data): a 'content' => data_get($data, 'content', $post->content), 'media' => data_get($data, 'media', $post->media), 'status' => $status === PostStatus::Publishing->value ? PostStatus::Publishing : $status, + 'publish_mode' => data_get($data, 'publish_mode', $post->publish_mode), 'scheduled_at' => $scheduledAt, ]); diff --git a/app/Console/Commands/ProcessScheduledPosts.php b/app/Console/Commands/ProcessScheduledPosts.php index 37a4789e4..66cad519f 100644 --- a/app/Console/Commands/ProcessScheduledPosts.php +++ b/app/Console/Commands/ProcessScheduledPosts.php @@ -4,8 +4,12 @@ namespace App\Console\Commands; +use App\Enums\Notification\Channel; +use App\Enums\Notification\Type as NotificationType; use App\Enums\Post\Status as PostStatus; use App\Jobs\PublishPost; +use App\Jobs\SendNotification; +use App\Mail\PostReadyForManualPublish; use App\Models\Post; use Illuminate\Console\Command; @@ -17,8 +21,9 @@ class ProcessScheduledPosts extends Command public function handle(): void { + // Auto-publish: claim due auto posts and dispatch the publisher. Post::query() - ->due() + ->dueForAutoPublish() ->each(function (Post $post) { // Atomically claim the post — only dispatch if we successfully change its status $claimed = Post::where('id', $post->id) @@ -29,5 +34,40 @@ public function handle(): void PublishPost::dispatch($post); } }); + + // Manual (notify-only): claim the one-time notification so a due manual + // post reminds the owner to publish it from the native app, and never + // auto-publishes. + Post::query() + ->with(['workspace.owner']) + ->manualDueNotNotified() + ->each(function (Post $post) { + $owner = $post->workspace?->owner; + + if (! $owner) { + $post->markManualPublishNotified(); + + return; + } + + // Atomically claim the notification (null guard) so a post that + // stays scheduled-notified isn't re-notified every minute. + $claimed = Post::where('id', $post->id) + ->whereNull('manual_publish_notified_at') + ->update(['manual_publish_notified_at' => now()]); + + if ($claimed) { + SendNotification::dispatch( + user: $owner, + workspaceId: $post->workspace_id, + type: NotificationType::PostManualPublishDue, + channel: Channel::Both, + title: trans('notifications.post_manual_publish_due.title', [], $post->workspace?->content_language), + body: trans('notifications.post_manual_publish_due.body', ['caption' => mb_strimwidth($post->content, 0, 120, '…')], $post->workspace?->content_language), + data: ['post_id' => $post->id], + mailable: new PostReadyForManualPublish($post), + ); + } + }); } } diff --git a/app/Enums/Notification/Type.php b/app/Enums/Notification/Type.php index 04263b505..e4fed1198 100644 --- a/app/Enums/Notification/Type.php +++ b/app/Enums/Notification/Type.php @@ -10,6 +10,7 @@ enum Type: string case PostFailed = 'post_failed'; case PostPartiallyPublished = 'post_partially_published'; case PostReady = 'post_ready'; + case PostManualPublishDue = 'post_manual_publish_due'; case AccountDisconnected = 'account_disconnected'; case InviteReceived = 'invite_received'; case MemberJoined = 'member_joined'; diff --git a/app/Enums/Post/PublishMode.php b/app/Enums/Post/PublishMode.php new file mode 100644 index 000000000..90ec6bdaf --- /dev/null +++ b/app/Enums/Post/PublishMode.php @@ -0,0 +1,24 @@ + __('posts.publish_mode.auto'), + self::Manual => __('posts.publish_mode.manual'), + }; + } + + public function isManual(): bool + { + return $this === self::Manual; + } +} diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 2796b80f4..f3ba84358 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Api\Post; +use App\Enums\Post\PublishMode; use App\Enums\Post\Status; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; @@ -39,6 +40,7 @@ public function rules(): array return [ 'status' => ['required', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])], + 'publish_mode' => ['sometimes', 'string', Rule::in(array_column(PublishMode::cases(), 'value'))], 'content' => [ 'nullable', 'string', diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 83647284d..344fed85a 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\App\Post; +use App\Enums\Post\PublishMode; use App\Enums\Post\Status; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; @@ -36,6 +37,7 @@ public function rules(): array return [ 'status' => ['required', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])], + 'publish_mode' => ['sometimes', 'string', Rule::in(array_column(PublishMode::cases(), 'value'))], 'content' => [ 'nullable', 'string', diff --git a/app/Http/Resources/Api/PostResource.php b/app/Http/Resources/Api/PostResource.php index 50436887a..b36c2e7ca 100644 --- a/app/Http/Resources/Api/PostResource.php +++ b/app/Http/Resources/Api/PostResource.php @@ -19,6 +19,7 @@ public function toArray(Request $request): array 'content' => $this->content, 'media' => $this->media, 'status' => $this->status?->value, + 'publish_mode' => $this->publish_mode?->value, 'scheduled_at' => $this->scheduled_at?->format('Y-m-d H:i:s'), 'published_at' => $this->published_at?->format('Y-m-d H:i:s'), 'platforms' => PostPlatformResource::collection($this->whenLoaded('postPlatforms')), diff --git a/app/Mail/PostReadyForManualPublish.php b/app/Mail/PostReadyForManualPublish.php new file mode 100644 index 000000000..c4449d157 --- /dev/null +++ b/app/Mail/PostReadyForManualPublish.php @@ -0,0 +1,67 @@ +post->workspace->name}", + ); + } + + public function content(): Content + { + $media = collect($this->post->media ?? []) + ->map(fn (array $item) => MediaItem::fromArray($item)) + ->filter(fn (MediaItem $item) => $item->isImage()) + ->take(6) + ->values() + ->all(); + + // User-friendly list of enabled platforms for the email's context line. + $platforms = $this->post->postPlatforms() + ->with('socialAccount') + ->where('enabled', true) + ->get() + ->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')') + ->values() + ->all(); + + return new Content( + view: 'mail.post-ready-manual-publish', + with: [ + 'title' => 'Your post is ready to publish', + 'previewText' => 'This post is due — publish it manually from the platform app.', + 'body' => 'This scheduled post is due. TryPost did not auto-publish it — share it from the native app so you can use app-only features (like adding music to an Instagram carousel), then mark it published.', + 'caption' => $this->post->content, + 'media' => $media, + 'platforms' => $platforms, + 'url' => route('app.posts.edit', $this->post), + ], + ); + } + + public function attachments(): array + { + return []; + } +} diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index a90e40305..27b416869 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -6,6 +6,7 @@ use App\Actions\Post\CreatePost; use App\Enums\Post\CreatedVia; +use App\Enums\Post\PublishMode; use App\Enums\PostPlatform\ContentType; use App\Http\Resources\Api\PostResource; use App\Mcp\Concerns\AuthorizesMcpTool; @@ -41,6 +42,7 @@ public function handle(Request $request): Response|ResponseFactory [ 'content' => ['nullable', 'string', 'max:10000'], 'scheduled_at' => ['nullable', 'date', 'after:now'], + 'publish_mode' => ['sometimes', 'string', Rule::in(array_column(PublishMode::cases(), 'value'))], 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $workspace->id)], 'platforms' => ['sometimes', 'array'], @@ -72,6 +74,9 @@ public function schema(JsonSchema $schema): array return [ 'content' => $schema->string()->description('The post caption/text body. Optional — can be edited later.'), 'scheduled_at' => $schema->string()->description('Optional ISO 8601 datetime in the future (e.g. 2026-05-10T15:30:00Z). Omit it or pass null to create an unscheduled draft.'), + 'publish_mode' => $schema->string() + ->enum(array_column(PublishMode::cases(), 'value')) + ->description('How the post publishes at its scheduled time: "auto" (default) auto-publishes, "manual" notifies you so you can publish it yourself from the native app.'), 'label_ids' => $schema->array() ->items($schema->string()) ->description('Workspace label IDs to attach to the post.'), diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 3f0ae0e62..7866a2911 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -6,6 +6,7 @@ use App\Actions\Post\UpdatePost; use App\Enums\Post\Action as PostAction; +use App\Enums\Post\PublishMode; use App\Enums\Post\Status; use App\Enums\PostPlatform\ContentType; use App\Http\Resources\Api\PostResource; @@ -52,6 +53,7 @@ public function handle(Request $request): Response|ResponseFactory 'post_id' => ['required', 'uuid'], 'content' => ['nullable', 'string', 'max:10000'], 'scheduled_at' => PostStatusRules::scheduledAtRules($post, $status), + 'publish_mode' => ['sometimes', 'string', Rule::in(array_column(PublishMode::cases(), 'value'))], 'status' => ['sometimes', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value])], 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $workspace->id)], @@ -109,6 +111,9 @@ public function schema(JsonSchema $schema): array 'post_id' => $schema->string()->required()->description('UUID of the post to update.'), 'content' => $schema->string()->description('New caption/text body.'), 'scheduled_at' => $schema->string()->description('Future ISO 8601 datetime. Required for status "scheduled" unless the post already has a future schedule.'), + 'publish_mode' => $schema->string() + ->enum(array_column(PublishMode::cases(), 'value')) + ->description('How the post publishes at its scheduled time: "auto" (default) auto-publishes, "manual" notifies you so you can publish it yourself from the native app.'), 'status' => $schema->string() ->enum([Status::Draft->value, Status::Scheduled->value]) ->description('Post status. Use "draft" to keep editing, "scheduled" to schedule the post. Use publish-post-tool for immediate publish.'), diff --git a/app/Models/Post.php b/app/Models/Post.php index 8d587d7f1..589a685ac 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -7,6 +7,7 @@ use App\DataTransferObjects\MediaItem; use App\Enums\Media\Type; use App\Enums\Post\CreatedVia; +use App\Enums\Post\PublishMode; use App\Enums\Post\Status as PostStatus; use App\Enums\SocialAccount\Platform; use App\Observers\PostObserver; @@ -35,6 +36,8 @@ class Post extends Model 'content', 'media', 'status', + 'publish_mode', + 'manual_publish_notified_at', 'created_via', 'scheduled_at', 'published_at', @@ -44,10 +47,12 @@ protected function casts(): array { return [ 'status' => PostStatus::class, + 'publish_mode' => PublishMode::class, 'created_via' => CreatedVia::class, 'media' => 'array', 'scheduled_at' => 'datetime', 'published_at' => 'datetime', + 'manual_publish_notified_at' => 'datetime', ]; } @@ -98,6 +103,25 @@ public function scopeDue(Builder $query): Builder return $query->scheduled()->where('scheduled_at', '<=', now()); } + /** + * Scheduled posts due for auto-publishing — excludes manual (notify-only) + * posts so the scheduler never auto-publishes them. + */ + public function scopeDueForAutoPublish(Builder $query): Builder + { + return $query->due()->where('publish_mode', PublishMode::Auto); + } + + /** + * Scheduled manual posts that haven't had their one-time notification sent yet. + */ + public function scopeManualDueNotNotified(Builder $query): Builder + { + return $query->due() + ->where('publish_mode', PublishMode::Manual) + ->whereNull('manual_publish_notified_at'); + } + public function scopeDraft(Builder $query): Builder { return $query->where('status', PostStatus::Draft); @@ -139,6 +163,18 @@ public function markAsFailed(): void $this->update(['status' => PostStatus::Failed]); } + public function isManualPublish(): bool + { + return ($this->publish_mode ?? PublishMode::Auto)->isManual(); + } + + public function markManualPublishNotified(): void + { + $this->update([ + 'manual_publish_notified_at' => now(), + ]); + } + /** * MediaTypes accepted by this post — the intersection of what every * enabled platform allows. With no platform enabled, accept anything. diff --git a/database/factories/PostFactory.php b/database/factories/PostFactory.php index f267de5cd..b583248fa 100644 --- a/database/factories/PostFactory.php +++ b/database/factories/PostFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Enums\Post\PublishMode; use App\Enums\Post\Status as PostStatus; use App\Models\Post; use App\Models\User; @@ -28,9 +29,17 @@ public function definition(): array 'content' => '', 'media' => [], 'status' => PostStatus::Draft, + 'publish_mode' => PublishMode::Auto, ]; } + public function manual(): static + { + return $this->state(fn (array $attributes) => [ + 'publish_mode' => PublishMode::Manual, + ]); + } + public function draft(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_08_06_182217_add_publish_mode_to_posts_table.php b/database/migrations/2026_08_06_182217_add_publish_mode_to_posts_table.php new file mode 100644 index 000000000..e99b5b3f0 --- /dev/null +++ b/database/migrations/2026_08_06_182217_add_publish_mode_to_posts_table.php @@ -0,0 +1,32 @@ +string('publish_mode')->default('auto')->after('status'); + $table->timestamp('manual_publish_notified_at')->nullable()->after('publish_mode'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn(['publish_mode', 'manual_publish_notified_at']); + }); + } +}; diff --git a/lang/ar/notifications.php b/lang/ar/notifications.php index 552b50284..22d25bb74 100644 --- a/lang/ar/notifications.php +++ b/lang/ar/notifications.php @@ -7,6 +7,11 @@ 'title' => 'منشورك جاهز', 'body' => 'أنهى الذكاء الاصطناعي عمله للتو. انقر للمراجعة والنشر.', ], + + 'post_manual_publish_due' => [ + 'title' => 'منشور مستحق للنشر اليدوي', + 'body' => 'هذا المنشور مستحق — انشره من التطبيق الأصلي: “:caption”', + ], 'account_disconnected' => [ 'title' => 'تم فصل حساب :platform', 'body' => 'يحتاج :account إلى إعادة الربط', diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 5bc649b3d..a4a968711 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -5,6 +5,15 @@ 'search' => 'البحث في المنشورات...', 'all_posts' => 'جميع المنشورات', 'new_post' => 'منشور جديد', + + 'publish_mode' => [ + 'auto' => 'نشر تلقائي', + 'auto_hint' => 'يقوم TryPost بنشر هذا المنشور تلقائيًا في الوقت المحدد.', + 'manual' => 'أخطرني للنشر يدويًا', + 'manual_hint' => 'يذكرك TryPost عند الاستحقاق — وتنشره بنفسك.', + 'schedule_hint' => 'وضع النشر', + 'manual_notice' => 'سيتم إشعارك عند الاستحقاق وتنشره بنفسك من التطبيق.', + ], 'no_posts' => 'لم يتم العثور على منشورات', 'no_search_results' => 'لا توجد منشورات مطابقة لبحثك', 'try_different_search' => 'جرّب كلمة مختلفة أو امسح البحث.', diff --git a/lang/de/notifications.php b/lang/de/notifications.php index 9d91e8d87..16e394522 100644 --- a/lang/de/notifications.php +++ b/lang/de/notifications.php @@ -7,6 +7,11 @@ 'title' => 'Dein Beitrag ist fertig', 'body' => 'Die KI ist gerade fertig geworden. Tippe, um ihn zu prüfen und zu veröffentlichen.', ], + + 'post_manual_publish_due' => [ + 'title' => 'Ein Beitrag ist zur manuellen Veröffentlichung fällig', + 'body' => 'Dieser Beitrag ist fällig — veröffentliche ihn in der App: „:caption“', + ], 'account_disconnected' => [ 'title' => ':platform-Konto getrennt', 'body' => ':account muss erneut verbunden werden', diff --git a/lang/de/posts.php b/lang/de/posts.php index 4445d1392..0732b3b44 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -7,6 +7,15 @@ 'search' => 'Beiträge suchen...', 'all_posts' => 'Alle Beiträge', 'new_post' => 'Neuer Beitrag', + + 'publish_mode' => [ + 'auto' => 'Automatisch veröffentlichen', + 'auto_hint' => 'TryPost veröffentlicht diesen Beitrag automatisch zur geplanten Zeit.', + 'manual' => 'Mich benachrichtigen, um manuell zu veröffentlichen', + 'manual_hint' => 'TryPost erinnert dich, wenn es soweit ist — du veröffentlichst selbst.', + 'schedule_hint' => 'Veröffentlichungsmodus', + 'manual_notice' => 'Du wirst benachrichtigt, wenn es soweit ist, und veröffentlichst selbst aus der App.', + ], 'no_posts' => 'Keine Beiträge gefunden', 'no_search_results' => 'Keine Beiträge passen zu deiner Suche', 'try_different_search' => 'Versuche ein anderes Stichwort oder setze die Suche zurück.', diff --git a/lang/el/notifications.php b/lang/el/notifications.php index 88f8ff080..c506fc21e 100644 --- a/lang/el/notifications.php +++ b/lang/el/notifications.php @@ -7,6 +7,11 @@ 'title' => 'Η δημοσίευσή σας είναι έτοιμη', 'body' => 'Το AI μόλις ολοκλήρωσε. Πατήστε για έλεγχο και δημοσίευση.', ], + + 'post_manual_publish_due' => [ + 'title' => 'Μια δημοσίευση είναι έτοιμη για χειροκίνητη δημοσίευση', + 'body' => 'Αυτή η δημοσίευση είναι έτοιμη — δημοσιεύστε τη στην εφαρμογή: “:caption”', + ], 'account_disconnected' => [ 'title' => 'Ο λογαριασμός :platform αποσυνδέθηκε', 'body' => 'Ο λογαριασμός :account χρειάζεται επανασύνδεση', diff --git a/lang/el/posts.php b/lang/el/posts.php index 1f2edd343..d471b60fb 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -5,6 +5,15 @@ 'search' => 'Αναζήτηση δημοσιεύσεων...', 'all_posts' => 'Όλες οι δημοσιεύσεις', 'new_post' => 'Νέα δημοσίευση', + + 'publish_mode' => [ + 'auto' => 'Αυτόματη δημοσίευση', + 'auto_hint' => 'Το TryPost δημοσιεύει αυτόματα αυτή τη δημοσίευση την προγραμματισμένη ώρα.', + 'manual' => 'Να με ειδοποιήσετε για χειροκίνητη δημοσίευση', + 'manual_hint' => 'Το TryPost σας υπενθυμίζει πότε είναι έτοιμο — δημοσιεύετε εσείς.', + 'schedule_hint' => 'Τρόπος δημοσίευσης', + 'manual_notice' => 'Θα ειδοποιηθείτε όταν είναι έτοιμο και θα το δημοσιεύσετε από την εφαρμογή.', + ], 'no_posts' => 'Δεν βρέθηκαν δημοσιεύσεις', 'no_search_results' => 'Καμία δημοσίευση δεν ταιριάζει με την αναζήτησή σας', 'try_different_search' => 'Δοκιμάστε διαφορετική λέξη-κλειδί ή καθαρίστε την αναζήτηση.', diff --git a/lang/en/notifications.php b/lang/en/notifications.php index 338fd5ee6..fd7520adc 100644 --- a/lang/en/notifications.php +++ b/lang/en/notifications.php @@ -7,6 +7,10 @@ 'title' => 'Your post is ready', 'body' => 'The AI just finished. Tap to review and publish.', ], + 'post_manual_publish_due' => [ + 'title' => 'A post is due for manual publish', + 'body' => 'This post is due — publish it from the native app: “:caption”', + ], 'account_disconnected' => [ 'title' => ':platform account disconnected', 'body' => ':account needs to be reconnected', diff --git a/lang/en/posts.php b/lang/en/posts.php index 918c0c299..6a92947ed 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -5,6 +5,15 @@ 'search' => 'Search posts...', 'all_posts' => 'All Posts', 'new_post' => 'New Post', + + 'publish_mode' => [ + 'auto' => 'Auto-publish', + 'auto_hint' => 'TryPost publishes this post automatically at the scheduled time.', + 'manual' => 'Notify me to publish manually', + 'manual_hint' => 'TryPost reminds you when it’s due — you publish it yourself.', + 'schedule_hint' => 'Publishing mode', + 'manual_notice' => 'You’ll be notified when it’s due and publish it yourself from the app.', + ], 'no_posts' => 'No posts found', 'no_search_results' => 'No posts match your search', 'try_different_search' => 'Try a different keyword or clear the search.', diff --git a/lang/es/notifications.php b/lang/es/notifications.php index 0f6620aa5..280a566eb 100644 --- a/lang/es/notifications.php +++ b/lang/es/notifications.php @@ -7,6 +7,11 @@ 'title' => 'Tu publicación está lista', 'body' => 'La IA terminó. Toca para revisar y publicar.', ], + + 'post_manual_publish_due' => [ + 'title' => 'Una publicación está lista para publicarse manualmente', + 'body' => 'Esta publicación está lista — publícala en la app: “:caption”', + ], 'account_disconnected' => [ 'title' => 'Cuenta de :platform desconectada', 'body' => ':account necesita reconectarse', diff --git a/lang/es/posts.php b/lang/es/posts.php index a2d8110bd..b3f6577cb 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -5,6 +5,15 @@ 'search' => 'Buscar posts...', 'all_posts' => 'Todos los posts', 'new_post' => 'Nuevo post', + + 'publish_mode' => [ + 'auto' => 'Publicar automáticamente', + 'auto_hint' => 'TryPost publica esta publicación automáticamente a la hora programada.', + 'manual' => 'Avisarme para publicar manualmente', + 'manual_hint' => 'TryPost te avisa cuando toca — la publicas tú.', + 'schedule_hint' => 'Modo de publicación', + 'manual_notice' => 'Recibirás un aviso cuando toque y la publicarás desde la app.', + ], 'no_posts' => 'No se encontraron posts', 'no_search_results' => 'Ningún post coincide con tu búsqueda', 'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.', diff --git a/lang/fr/notifications.php b/lang/fr/notifications.php index dd8b0b477..ded6000ab 100644 --- a/lang/fr/notifications.php +++ b/lang/fr/notifications.php @@ -7,6 +7,11 @@ 'title' => 'Votre publication est prête', 'body' => 'L\'IA vient de terminer. Touchez pour relire et publier.', ], + + 'post_manual_publish_due' => [ + 'title' => 'Une publication est prête à être publiée manuellement', + 'body' => 'Cette publication est prête — publiez-la dans l’application : “:caption”', + ], 'account_disconnected' => [ 'title' => 'Compte :platform déconnecté', 'body' => ':account doit être reconnecté', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 6e7ffc3ba..cdcd5b1c2 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -5,6 +5,15 @@ 'search' => 'Rechercher des publications...', 'all_posts' => 'Toutes les publications', 'new_post' => 'Nouvelle publication', + + 'publish_mode' => [ + 'auto' => 'Publication automatique', + 'auto_hint' => 'TryPost publie cette publication automatiquement à l’heure prévue.', + 'manual' => 'Me prévenir pour publier manuellement', + 'manual_hint' => 'TryPost vous prévient quand c’est l’heure — vous la publiez vous-même.', + 'schedule_hint' => 'Mode de publication', + 'manual_notice' => 'Vous serez prévenu quand ce sera l’heure et la publierez depuis l’application.', + ], 'no_posts' => 'Aucune publication trouvée', 'no_search_results' => 'Aucune publication ne correspond à votre recherche', 'try_different_search' => 'Essayez un autre mot-clé ou effacez la recherche.', diff --git a/lang/it/notifications.php b/lang/it/notifications.php index 23e602502..439f61df8 100644 --- a/lang/it/notifications.php +++ b/lang/it/notifications.php @@ -7,6 +7,11 @@ 'title' => 'Il tuo post è pronto', 'body' => 'L\'IA ha appena finito. Tocca per rivedere e pubblicare.', ], + + 'post_manual_publish_due' => [ + 'title' => 'Un post è pronto per la pubblicazione manuale', + 'body' => 'Questo post è pronto — pubblicalo nell’app: “:caption”', + ], 'account_disconnected' => [ 'title' => 'Account :platform scollegato', 'body' => ':account deve essere ricollegato', diff --git a/lang/it/posts.php b/lang/it/posts.php index 4d08795fc..964bc4560 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -5,6 +5,15 @@ 'search' => 'Cerca post...', 'all_posts' => 'Tutti i post', 'new_post' => 'Nuovo post', + + 'publish_mode' => [ + 'auto' => 'Pubblicazione automatica', + 'auto_hint' => 'TryPost pubblica automaticamente questo post all’orario programmato.', + 'manual' => 'Avvisami per pubblicare manualmente', + 'manual_hint' => 'TryPost ti avvisa quando è il momento — pubblichi tu.', + 'schedule_hint' => 'Modalità di pubblicazione', + 'manual_notice' => 'Riceverai un avviso quando è il momento e lo pubblicherai dall’app.', + ], 'no_posts' => 'Nessun post trovato', 'no_search_results' => 'Nessun post corrisponde alla ricerca', 'try_different_search' => 'Prova con un\'altra parola chiave o cancella la ricerca.', diff --git a/lang/ja/notifications.php b/lang/ja/notifications.php index f2f2ccb2f..2c4af5cf1 100644 --- a/lang/ja/notifications.php +++ b/lang/ja/notifications.php @@ -7,6 +7,11 @@ 'title' => '投稿の準備ができました', 'body' => 'AI の処理が完了しました。タップして確認・公開してください。', ], + + 'post_manual_publish_due' => [ + 'title' => '手動公開が可能な投稿があります', + 'body' => 'この投稿の公開時期です — アプリから公開してください: “:caption”', + ], 'account_disconnected' => [ 'title' => ':platform アカウントの接続が解除されました', 'body' => ':account の再接続が必要です', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index 1af1a4431..2465011ae 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -5,6 +5,15 @@ 'search' => '投稿を検索...', 'all_posts' => 'すべての投稿', 'new_post' => '新規投稿', + + 'publish_mode' => [ + 'auto' => '自動公開', + 'auto_hint' => 'TryPostは予定した時刻に自動的に公開します。', + 'manual' => '手動で公開するよう通知する', + 'manual_hint' => 'TryPostが公開時刻になるとお知らせします — ご自身で公開します。', + 'schedule_hint' => '公開モード', + 'manual_notice' => '公開時刻になると通知され、アプリからご自身で公開します。', + ], 'no_posts' => '投稿が見つかりません', 'no_search_results' => '検索に一致する投稿がありません', 'try_different_search' => '別のキーワードを試すか、検索をクリアしてください。', diff --git a/lang/ko/notifications.php b/lang/ko/notifications.php index a7534682b..2f1445545 100644 --- a/lang/ko/notifications.php +++ b/lang/ko/notifications.php @@ -7,6 +7,11 @@ 'title' => '게시물이 준비되었습니다', 'body' => 'AI 작업이 방금 완료되었습니다. 탭하여 검토하고 게시하세요.', ], + + 'post_manual_publish_due' => [ + 'title' => '수동 게시할 게시물이 있습니다', + 'body' => '이 게시물은 게시할 때입니다 — 앱에서 게시하세요: “:caption”', + ], 'account_disconnected' => [ 'title' => ':platform 계정 연결 해제됨', 'body' => ':account을(를) 재연결해야 합니다', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index bd8c1ab98..b6b846db3 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -5,6 +5,15 @@ 'search' => '게시물 검색...', 'all_posts' => '모든 게시물', 'new_post' => '새 게시물', + + 'publish_mode' => [ + 'auto' => '자동 게시', + 'auto_hint' => 'TryPost가 예약된 시간에 자동으로 게시합니다.', + 'manual' => '수동 게시를 위해 알려주기', + 'manual_hint' => 'TryPost가 게시 시간이 되면 알려줍니다 — 직접 게시합니다.', + 'schedule_hint' => '게시 모드', + 'manual_notice' => '게시 시간이 되면 알림을 받고 앱에서 직접 게시합니다.', + ], 'no_posts' => '게시물을 찾을 수 없습니다', 'no_search_results' => '검색과 일치하는 게시물이 없습니다', 'try_different_search' => '다른 키워드로 시도하거나 검색을 지우세요.', diff --git a/lang/nl/notifications.php b/lang/nl/notifications.php index aa97841e2..a0aa6535c 100644 --- a/lang/nl/notifications.php +++ b/lang/nl/notifications.php @@ -7,6 +7,11 @@ 'title' => 'Je post is klaar', 'body' => 'De AI is net klaar. Tik om te bekijken en te publiceren.', ], + + 'post_manual_publish_due' => [ + 'title' => 'Een bericht is klaar om handmatig te publiceren', + 'body' => 'Dit bericht is klaar — publiceer het in de app: “:caption”', + ], 'account_disconnected' => [ 'title' => ':platform-account losgekoppeld', 'body' => ':account moet opnieuw worden gekoppeld', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 9a83be401..0cc8bee8e 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -5,6 +5,15 @@ 'search' => 'Posts zoeken...', 'all_posts' => 'Alle posts', 'new_post' => 'Nieuwe post', + + 'publish_mode' => [ + 'auto' => 'Automatisch publiceren', + 'auto_hint' => 'TryPost publiceert dit bericht automatisch op de geplande tijd.', + 'manual' => 'Mij waarschuwen om handmatig te publiceren', + 'manual_hint' => 'TryPost herinnert je wanneer het zover is — jij publiceert zelf.', + 'schedule_hint' => 'Publicatiemodus', + 'manual_notice' => 'Je wordt gewaarschuwd wanneer het zover is en publiceert vanuit de app.', + ], 'no_posts' => 'Geen posts gevonden', 'no_search_results' => 'Geen posts komen overeen met je zoekopdracht', 'try_different_search' => 'Probeer een ander zoekwoord of wis de zoekopdracht.', diff --git a/lang/pl/notifications.php b/lang/pl/notifications.php index 52c29ef69..2dc9b9aa2 100644 --- a/lang/pl/notifications.php +++ b/lang/pl/notifications.php @@ -7,6 +7,11 @@ 'title' => 'Twój post jest gotowy', 'body' => 'AI właśnie skończyła. Dotknij, aby sprawdzić i opublikować.', ], + + 'post_manual_publish_due' => [ + 'title' => 'Post jest gotowy do ręcznej publikacji', + 'body' => 'Ten post jest gotowy — opublikuj go w aplikacji: „:caption”', + ], 'account_disconnected' => [ 'title' => 'Konto :platform zostało rozłączone', 'body' => ':account wymaga ponownego połączenia', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index c040718f7..346dc671b 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -5,6 +5,15 @@ 'search' => 'Szukaj postów...', 'all_posts' => 'Wszystkie posty', 'new_post' => 'Nowy post', + + 'publish_mode' => [ + 'auto' => 'Automatyczna publikacja', + 'auto_hint' => 'TryPost publikuje ten post automatycznie o zaplanowanej godzinie.', + 'manual' => 'Powiadom mnie, abym opublikował ręcznie', + 'manual_hint' => 'TryPost przypomni Ci, gdy nadejdzie czas — publikujesz sam.', + 'schedule_hint' => 'Tryb publikacji', + 'manual_notice' => 'Otrzymasz powiadomienie, gdy nadejdzie czas, i opublikujesz z aplikacji.', + ], 'no_posts' => 'Nie znaleziono postów', 'no_search_results' => 'Brak postów pasujących do wyszukiwania', 'try_different_search' => 'Spróbuj innego słowa kluczowego lub wyczyść wyszukiwanie.', diff --git a/lang/pt-BR/notifications.php b/lang/pt-BR/notifications.php index f929f57cb..9965d4fb9 100644 --- a/lang/pt-BR/notifications.php +++ b/lang/pt-BR/notifications.php @@ -7,6 +7,11 @@ 'title' => 'Seu post está pronto', 'body' => 'A AI terminou. Toque pra revisar e publicar.', ], + + 'post_manual_publish_due' => [ + 'title' => 'Um post está pronto para publicação manual', + 'body' => 'Este post está pronto — publique-o no aplicativo: “:caption”', + ], 'account_disconnected' => [ 'title' => 'Conta do :platform desconectada', 'body' => ':account precisa ser reconectada', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 10ccc0fe8..6ac2cd488 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -5,6 +5,15 @@ 'search' => 'Buscar posts...', 'all_posts' => 'Todos os Posts', 'new_post' => 'Novo Post', + + 'publish_mode' => [ + 'auto' => 'Publicação automática', + 'auto_hint' => 'O TryPost publica este post automaticamente no horário agendado.', + 'manual' => 'Notificar-me para publicar manualmente', + 'manual_hint' => 'O TryPost avisa quando chegar a hora — você publica.', + 'schedule_hint' => 'Modo de publicação', + 'manual_notice' => 'Você será notificado quando chegar a hora e publicará pelo aplicativo.', + ], 'no_posts' => 'Nenhum post encontrado', 'no_search_results' => 'Nenhum post corresponde à sua busca', 'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.', diff --git a/lang/ru/notifications.php b/lang/ru/notifications.php index 85e0aa42c..ab1f72a9f 100644 --- a/lang/ru/notifications.php +++ b/lang/ru/notifications.php @@ -7,6 +7,11 @@ 'title' => 'Ваш пост готов', 'body' => 'ИИ только что завершил работу. Нажмите, чтобы просмотреть и опубликовать.', ], + + 'post_manual_publish_due' => [ + 'title' => 'Пост готов к ручной публикации', + 'body' => 'Этот пост готов — опубликуйте его в приложении: «:caption»', + ], 'account_disconnected' => [ 'title' => 'Аккаунт :platform отключён', 'body' => ':account требует переподключения', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 3c83c63e6..49d8d27b0 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -5,6 +5,15 @@ 'search' => 'Поиск постов...', 'all_posts' => 'Все посты', 'new_post' => 'Новый пост', + + 'publish_mode' => [ + 'auto' => 'Автопубликация', + 'auto_hint' => 'TryPost публикует этот пост автоматически в запланированное время.', + 'manual' => 'Уведомить меня для ручной публикации', + 'manual_hint' => 'TryPost напомнит, когда наступит время — публикуете вы.', + 'schedule_hint' => 'Режим публикации', + 'manual_notice' => 'Вы получите уведомление, когда наступит время, и опубликуете из приложения.', + ], 'no_posts' => 'Посты не найдены', 'no_search_results' => 'Нет постов по вашему запросу', 'try_different_search' => 'Попробуйте другое ключевое слово или очистите поиск.', diff --git a/lang/tr/notifications.php b/lang/tr/notifications.php index 94ca6b1bf..fb768dbd1 100644 --- a/lang/tr/notifications.php +++ b/lang/tr/notifications.php @@ -7,6 +7,11 @@ 'title' => 'Gönderiniz hazır', 'body' => 'AI az önce bitirdi. İncelemek ve yayınlamak için dokunun.', ], + + 'post_manual_publish_due' => [ + 'title' => 'Manuel yayın için bir gönderi hazır', + 'body' => 'Bu gönderi hazır — uygulamadan yayınlayın: “:caption”', + ], 'account_disconnected' => [ 'title' => ':platform hesabının bağlantısı kesildi', 'body' => ':account yeniden bağlanmalı', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 22e9f3e95..52f4814b1 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -7,6 +7,15 @@ 'search' => 'Gönderi ara...', 'all_posts' => 'Tüm Gönderiler', 'new_post' => 'Yeni Gönderi', + + 'publish_mode' => [ + 'auto' => 'Otomatik yayınla', + 'auto_hint' => 'TryPost bu gönderiyi planlanan saatte otomatik olarak yayınlar.', + 'manual' => 'Manuel yayınlamam için bana bildir', + 'manual_hint' => 'TryPost zamanı geldiğinde hatırlatır — siz yayınlarsınız.', + 'schedule_hint' => 'Yayın modu', + 'manual_notice' => 'Zamanı geldiğinde bildirim alır ve uygulamadan yayınlarsınız.', + ], 'no_posts' => 'Gönderi bulunamadı', 'no_search_results' => 'Aramanızla eşleşen gönderi yok', 'try_different_search' => 'Farklı bir anahtar kelime deneyin veya aramayı temizleyin.', diff --git a/lang/uk/notifications.php b/lang/uk/notifications.php index 0987d4518..cdf8b28c3 100644 --- a/lang/uk/notifications.php +++ b/lang/uk/notifications.php @@ -7,6 +7,11 @@ 'title' => 'Ваш пост готовий', 'body' => 'AI щойно завершив роботу. Натисніть, щоб переглянути та опублікувати.', ], + + 'post_manual_publish_due' => [ + 'title' => 'Пост готовий до ручної публікації', + 'body' => 'Цей пост готовий — опублікуйте його в застосунку: «:caption»', + ], 'account_disconnected' => [ 'title' => 'Акаунт :platform від’єднано', 'body' => ':account потрібно перепідключити', diff --git a/lang/uk/posts.php b/lang/uk/posts.php index 50a859103..654735410 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -5,6 +5,15 @@ 'search' => 'Пошук постів...', 'all_posts' => 'Усі пости', 'new_post' => 'Новий пост', + + 'publish_mode' => [ + 'auto' => 'Автопублікація', + 'auto_hint' => 'TryPost публікує цей пост автоматично в запланований час.', + 'manual' => 'Повідомити мене для ручної публікації', + 'manual_hint' => 'TryPost нагадає, коли настане час — публікуєте ви.', + 'schedule_hint' => 'Режим публікації', + 'manual_notice' => 'Ви отримаєте сповіщення, коли настане час, і опублікуєте з застосунку.', + ], 'no_posts' => 'Постів не знайдено', 'no_search_results' => 'Жоден пост не відповідає вашому пошуку', 'try_different_search' => 'Спробуйте інше ключове слово або очистіть пошук.', diff --git a/lang/zh/notifications.php b/lang/zh/notifications.php index 643c912f1..9a3cfff08 100644 --- a/lang/zh/notifications.php +++ b/lang/zh/notifications.php @@ -7,6 +7,11 @@ 'title' => '你的帖子已就绪', 'body' => 'AI 刚刚完成。点击查看并发布。', ], + + 'post_manual_publish_due' => [ + 'title' => '有帖子可以手动发布了', + 'body' => '此帖子已可发布 — 请在应用中发布:“:caption”', + ], 'account_disconnected' => [ 'title' => ':platform 账号已断开连接', 'body' => ':account 需要重新连接', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 4f281001c..43829d525 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -5,6 +5,15 @@ 'search' => '搜索帖子…', 'all_posts' => '所有帖子', 'new_post' => '新建帖子', + + 'publish_mode' => [ + 'auto' => '自动发布', + 'auto_hint' => 'TryPost 会在预定时间自动发布此帖子。', + 'manual' => '通知我手动发布', + 'manual_hint' => 'TryPost 会在到期时提醒您 — 由您自己发布。', + 'schedule_hint' => '发布模式', + 'manual_notice' => '到期时会收到通知,并可从应用中自己发布。', + ], 'no_posts' => '未找到帖子', 'no_search_results' => '没有与搜索匹配的帖子', 'try_different_search' => '换一个关键词,或清除搜索。', diff --git a/resources/js/components/posts/editor/PostEditorTabs.vue b/resources/js/components/posts/editor/PostEditorTabs.vue index 039dfbf83..e95aa3f72 100644 --- a/resources/js/components/posts/editor/PostEditorTabs.vue +++ b/resources/js/components/posts/editor/PostEditorTabs.vue @@ -7,6 +7,7 @@ import ScheduleTab from '@/components/posts/editor/ScheduleTab.vue'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import type { PinterestBoardsPayload } from '@/types'; import type { MediaItem } from '@/types/media'; +import type { PostPublishModeValue } from '@/types/post'; interface SocialAccount { id: string; @@ -67,6 +68,7 @@ const props = defineProps<{ authUserId: string; initialHighlightCommentId: string | null; postedAt?: string | null; + publishMode: PostPublishModeValue; }>(); const activeTab = defineModel('activeTab', { required: true }); @@ -76,6 +78,7 @@ const emit = defineEmits<{ (e: 'toggle-label', labelId: string): void; (e: 'update:platformMeta', platformId: string, meta: Record): void; (e: 'update:platformContentType', platformId: string, contentType: string): void; + (e: 'update:publishMode', value: PostPublishModeValue): void; }>(); const commentsTabRef = ref | null>(null); @@ -123,10 +126,12 @@ defineExpose({ :tiktok-creator-infos="tiktokCreatorInfos" :pinterest-boards="pinterestBoards" :media="media" + :publish-mode="publishMode" @toggle-platform="(id) => emit('toggle-platform', id)" @toggle-label="(id) => emit('toggle-label', id)" @update:platform-meta="(id, meta) => emit('update:platformMeta', id, meta)" @update:platform-content-type="(id, contentType) => emit('update:platformContentType', id, contentType)" + @update:publish-mode="(value) => emit('update:publishMode', value)" /> diff --git a/resources/js/components/posts/editor/ScheduleTab.vue b/resources/js/components/posts/editor/ScheduleTab.vue index f05e90f54..20391662c 100644 --- a/resources/js/components/posts/editor/ScheduleTab.vue +++ b/resources/js/components/posts/editor/ScheduleTab.vue @@ -5,13 +5,14 @@ import { computed } from 'vue'; import ChannelConfigurator from '@/components/ChannelConfigurator.vue'; import LabelBadge from '@/components/labels/LabelBadge.vue'; import { Badge } from '@/components/ui/badge'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { usePageErrors } from '@/composables/usePageErrors'; import { getPlatformLogo } from '@/composables/usePlatformLogo'; import { isVideo } from '@/lib/mediaType'; import type { PinterestBoard, PinterestBoardsPayload } from '@/types'; import type { Channel } from '@/types/channel'; import type { MediaItem } from '@/types/media'; -import { PostPlatformStatus } from '@/types/post'; +import { PostPublishMode, type PostPublishModeValue, PostPlatformStatus } from '@/types/post'; interface SocialAccount { id: string; @@ -79,6 +80,7 @@ const props = defineProps<{ tiktokCreatorInfos?: Record | null; pinterestBoards?: Record | null; media?: MediaItem[]; + publishMode: PostPublishModeValue; }>(); const emit = defineEmits<{ @@ -86,6 +88,7 @@ const emit = defineEmits<{ toggleLabel: [labelId: string]; 'update:platformMeta': [platformId: string, meta: Record]; 'update:platformContentType': [platformId: string, contentType: string]; + 'update:publishMode': [value: PostPublishModeValue]; }>(); const getPublishConfig = (pp: PostPlatform): Record | null => @@ -209,6 +212,45 @@ const channels = computed(() => +
+

+ {{ $t('posts.publish_mode.schedule_hint') }} +

+ + + + +

+ {{ $t('posts.publish_mode.manual_notice') }} +

+
+

{{ $t('posts.edit.labels') }} diff --git a/resources/js/pages/posts/Edit.vue b/resources/js/pages/posts/Edit.vue index 82dd5fa20..00ee50893 100644 --- a/resources/js/pages/posts/Edit.vue +++ b/resources/js/pages/posts/Edit.vue @@ -26,7 +26,7 @@ import AppLayout from '@/layouts/AppLayout.vue'; import { destroy as destroyPost, update as updatePost } from '@/routes/app/posts'; import type { PinterestBoardsPayload } from '@/types'; import type { MediaItem } from '@/types/media'; -import { PostStatus } from '@/types/post'; +import { PostPublishMode, type PostPublishModeValue, PostStatus } from '@/types/post'; interface SocialAccount { id: string; @@ -58,6 +58,7 @@ interface Post { content: string; media: MediaItem[]; status: string; + publish_mode: string | null; scheduled_at: string | null; published_at: string | null; post_platforms: PostPlatform[]; @@ -133,6 +134,10 @@ const updatePlatformContentType = (platformId: string, contentType: string) => { platformContentTypes.value = { ...platformContentTypes.value, [platformId]: contentType }; }; +const updatePublishMode = (value: PostPublishModeValue) => { + publishMode.value = value; +}; + const { platformLimits, mediaIssues, @@ -153,6 +158,10 @@ const { const scheduledDateTime = ref(date.formatUtcForDateTimeLocalInput(post.value.scheduled_at)); const hasPickedTime = ref(Boolean(post.value.scheduled_at)); +const publishMode = ref( + (post.value.publish_mode as PostPublishModeValue) || PostPublishMode.Auto, +); + const pickTimeLabel = computed(() => { if (! hasPickedTime.value || ! scheduledDateTime.value) { return trans('posts.edit.pick_time'); @@ -270,6 +279,7 @@ const getSubmitData = () => { media: media.value, platforms, scheduled_at: date.formatLocalDateTimeForApi(scheduledDateTime.value), + publish_mode: publishMode.value, label_ids: selectedLabelIds.value, }; }; @@ -310,7 +320,7 @@ const triggerAutosave = () => { } }; -watch([content, media, selectedPlatformIds, scheduledDateTime, selectedLabelIds, platformMeta, platformContentTypes], triggerAutosave, { deep: true }); +watch([content, media, selectedPlatformIds, scheduledDateTime, selectedLabelIds, platformMeta, platformContentTypes, publishMode], triggerAutosave, { deep: true }); onUnmounted(() => { debouncedSave.cancel(); @@ -465,10 +475,12 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => { :auth-user-id="authUserId" :initial-highlight-comment-id="initialHighlightCommentId" :posted-at="scheduledDateTime || null" + :publish-mode="publishMode" @toggle-platform="togglePlatform" @toggle-label="toggleLabel" @update:platform-meta="updatePlatformMeta" @update:platform-content-type="updatePlatformContentType" + @update:publish-mode="updatePublishMode" />

diff --git a/resources/js/types/post.ts b/resources/js/types/post.ts index eaa88ee76..e6c1d9dc7 100644 --- a/resources/js/types/post.ts +++ b/resources/js/types/post.ts @@ -9,6 +9,13 @@ export const PostStatus = { export type PostStatusValue = (typeof PostStatus)[keyof typeof PostStatus]; +export const PostPublishMode = { + Auto: 'auto', + Manual: 'manual', +} as const; + +export type PostPublishModeValue = (typeof PostPublishMode)[keyof typeof PostPublishMode]; + export const PostPlatformStatus = { Pending: 'pending', Publishing: 'publishing', diff --git a/resources/views/mail/post-ready-manual-publish.blade.php b/resources/views/mail/post-ready-manual-publish.blade.php new file mode 100644 index 000000000..acca4b0cf --- /dev/null +++ b/resources/views/mail/post-ready-manual-publish.blade.php @@ -0,0 +1,68 @@ + + + + + + + + + {{ $title }} + + +
{{ $previewText }}  ͏
+
+
+ + + + + + + +
+ + + + +
+

{{ $title }}

+

{{ $body }}

+ + @if(!empty($platforms)) +
+ Publish to: + {{ implode(', ', $platforms) }} +
+ @endif + + @if(!empty($caption)) +
{{ $caption }}
+ @endif + + @if(!empty($media)) +
+ @foreach($media as $item) + {{ $item->altText() ?? '' }} + @endforeach +
+ @endif + +
+ +
+
+

Open-source social media scheduling tool

+ @if(isset($unsubscribe_url)) +

+ Unsubscribe +

+ @endif +
+
+
+ + diff --git a/tests/Feature/Actions/Post/CreatePostTest.php b/tests/Feature/Actions/Post/CreatePostTest.php index 3a50dceee..6df500611 100644 --- a/tests/Feature/Actions/Post/CreatePostTest.php +++ b/tests/Feature/Actions/Post/CreatePostTest.php @@ -4,6 +4,7 @@ use App\Actions\Post\CreatePost; use App\Enums\Post\CreatedVia; +use App\Enums\Post\PublishMode; use App\Events\PostCreated; use App\Models\User; use App\Models\Workspace; @@ -54,3 +55,26 @@ expect($post->fresh()->created_via)->toBeNull(); }); + +test('execute defaults publish mode to auto', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + + $post = CreatePost::execute($workspace, $user, [ + 'content' => 'Hello world', + ]); + + expect($post->fresh()->publish_mode)->toBe(PublishMode::Auto); +}); + +test('execute persists manual publish mode', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + + $post = CreatePost::execute($workspace, $user, [ + 'content' => 'Hello world', + 'publish_mode' => PublishMode::Manual, + ]); + + expect($post->fresh()->publish_mode)->toBe(PublishMode::Manual); +}); diff --git a/tests/Feature/Commands/ProcessScheduledPostsTest.php b/tests/Feature/Commands/ProcessScheduledPostsTest.php index 0b85d5428..1447dc7c4 100644 --- a/tests/Feature/Commands/ProcessScheduledPostsTest.php +++ b/tests/Feature/Commands/ProcessScheduledPostsTest.php @@ -3,8 +3,10 @@ declare(strict_types=1); use App\Console\Commands\ProcessScheduledPosts; +use App\Enums\Notification\Type; use App\Enums\Post\Status as PostStatus; use App\Jobs\PublishPost; +use App\Jobs\SendNotification; use App\Models\Post; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -106,3 +108,100 @@ Queue::assertPushed(PublishPost::class, 3); }); + +test('manual mode does not auto-publish and notifies the owner once', function () { + Queue::fake(); + + $socialAccount = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]); + + $duePost = Post::factory()->manual()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Scheduled, + 'scheduled_at' => now()->subMinute(), + 'content' => 'Post this manually, please.', + ]); + + PostPlatform::factory()->create([ + 'post_id' => $duePost->id, + 'social_account_id' => $socialAccount->id, + ]); + + // First pass: notify, do not publish. + $this->artisan(ProcessScheduledPosts::class)->assertSuccessful(); + + Queue::assertNotPushed(PublishPost::class); + Queue::assertPushed(SendNotification::class, function ($job) use ($duePost) { + return $job->user->id === $this->user->id + && data_get($job->data, 'post_id') === $duePost->id + && $job->type === Type::PostManualPublishDue; + }); + + // Second pass: already notified, must NOT notify again. + $this->artisan(ProcessScheduledPosts::class)->assertSuccessful(); + + Queue::assertPushed(SendNotification::class, 1); +}); + +test('manual mode stays scheduled after notification', function () { + Queue::fake(); + + $duePost = Post::factory()->manual()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Scheduled, + 'scheduled_at' => now()->subMinute(), + ]); + + $this->artisan(ProcessScheduledPosts::class)->assertSuccessful(); + + expect($duePost->fresh()->status)->toBe(PostStatus::Scheduled) + ->and($duePost->fresh()->manual_publish_notified_at)->not->toBeNull(); +}); + +test('manual future post is not notified before its schedule', function () { + Queue::fake(); + + $futurePost = Post::factory()->manual()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Scheduled, + 'scheduled_at' => now()->addDay(), + ]); + + $this->artisan(ProcessScheduledPosts::class)->assertSuccessful(); + + Queue::assertNotPushed(SendNotification::class); + expect($futurePost->fresh()->manual_publish_notified_at)->toBeNull(); +}); + +test('auto posts publish even when a manual post is due', function () { + Queue::fake(); + + $socialAccount = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]); + + Post::factory()->manual()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Scheduled, + 'scheduled_at' => now()->subMinute(), + ]); + + $dueAuto = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Scheduled, + 'scheduled_at' => now()->subMinute(), + ]); + + PostPlatform::factory()->create([ + 'post_id' => $dueAuto->id, + 'social_account_id' => $socialAccount->id, + ]); + + $this->artisan(ProcessScheduledPosts::class)->assertSuccessful(); + + Queue::assertPushed(PublishPost::class, function ($job) use ($dueAuto) { + return $job->post->id === $dueAuto->id; + }); +}); diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index 57ceeba2f..c12bfa293 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -43,7 +43,7 @@ $response->assertOk() ->assertStructuredContent(function (AssertableJson $json) { $json->has('posts', 3, function (AssertableJson $post) { - $post->hasAll(['id', 'content', 'media', 'status', 'scheduled_at', 'published_at', 'platforms', 'labels', 'created_at', 'updated_at']) + $post->hasAll(['id', 'content', 'media', 'status', 'publish_mode', 'scheduled_at', 'published_at', 'platforms', 'labels', 'created_at', 'updated_at']) ->missing('user_id') ->missing('workspace_id'); });