Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/Actions/Post/CreatePost.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -34,6 +35,7 @@ class CreatePost
* media?: array<int, mixed>,
* date?: ?string,
* scheduled_at?: ?string,
* publish_mode?: ?PublishMode,
* created_via?: ?CreatedVia,
* platforms?: array<int, array{social_account_id: string, content_type?: string, meta?: array<string, mixed>}>,
* label_ids?: array<int, string>
Expand All @@ -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,
]);
Expand Down
1 change: 1 addition & 0 deletions app/Actions/Post/UpdatePost.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);

Expand Down
41 changes: 40 additions & 1 deletion app/Console/Commands/ProcessScheduledPosts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)
Expand All @@ -29,5 +34,39 @@ 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()
->manualDueNotNotified()
->each(function (Post $post) {
$owner = $post->workspace?->owner;
Comment on lines +41 to +45

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),
);
}
});
}
}
1 change: 1 addition & 0 deletions app/Enums/Notification/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
24 changes: 24 additions & 0 deletions app/Enums/Post/PublishMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace App\Enums\Post;

enum PublishMode: string
{
case Auto = 'auto';
case Manual = 'manual';

public function label(): string
{
return match ($this) {
self::Auto => __('posts.publish_mode.auto'),
self::Manual => __('posts.publish_mode.manual'),
};
}

public function isManual(): bool
{
return $this === self::Manual;
}
}
2 changes: 2 additions & 0 deletions app/Http/Requests/Api/Post/UpdatePostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Requests/App/Post/UpdatePostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions app/Http/Resources/Api/PostResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down
67 changes: 67 additions & 0 deletions app/Mail/PostReadyForManualPublish.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace App\Mail;

use App\DataTransferObjects\MediaItem;
use App\Models\Post;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class PostReadyForManualPublish extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;

public function __construct(
public Post $post
) {}

public function envelope(): Envelope
{
return new Envelope(
subject: 'Your post is ready to publish — '.$this->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 [];
}
}
5 changes: 5 additions & 0 deletions app/Mcp/Tools/Post/CreatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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.'),
Expand Down
5 changes: 5 additions & 0 deletions app/Mcp/Tools/Post/UpdatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)],
Expand Down Expand Up @@ -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.'),
Expand Down
36 changes: 36 additions & 0 deletions app/Models/Post.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,6 +36,8 @@ class Post extends Model
'content',
'media',
'status',
'publish_mode',
'manual_publish_notified_at',
'created_via',
'scheduled_at',
'published_at',
Expand All @@ -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',
];
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions database/factories/PostFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) => [
Expand Down
Loading