Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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|string|null,
* 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
42 changes: 41 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,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),
);
}
});
}
}
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