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
8 changes: 6 additions & 2 deletions app/Actions/Post/CreatePost.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,17 @@ public static function execute(Workspace $workspace, User $user, array $data): P
/**
* @param array<string, mixed> $data
*/
private static function resolveScheduledAt(array $data): Carbon
private static function resolveScheduledAt(array $data): ?Carbon
{
if ($scheduledAt = data_get($data, 'scheduled_at')) {
return Carbon::parse($scheduledAt)->utc();
}

$date = data_get($data, 'date') ?: Carbon::now('UTC')->format('Y-m-d');
$date = data_get($data, 'date');

if (blank($date)) {
return null;
}

return Carbon::parse($date, 'UTC')->setTime(9, 0)->utc();
}
Expand Down
34 changes: 15 additions & 19 deletions app/Http/Requests/Api/Post/UpdatePostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use App\Rules\ContentTypeMatchesPostPlatform;
use App\Support\PostMediaRules;
use App\Support\PostPlatformMetaRules;
use App\Support\PostStatusRules;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
Expand All @@ -28,8 +29,10 @@ public function authorize(): bool

public function rules(): array
{
$status = PostStatusRules::normalizeStatus($this->input('status'));

$enforcesPlatformLimits = in_array(
$this->input('status'),
$status,
[Status::Scheduled->value, Status::Publishing->value],
true,
);
Expand All @@ -47,22 +50,15 @@ public function rules(): array
],
...PostMediaRules::rules(hosted: false),
'platforms' => ['sometimes', 'array'],
'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post') instanceof Post ? $this->route('post')->id : $this->route('post'))],
'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id)],
'platforms.*.content_type' => [
'sometimes',
'string',
Rule::in(array_column(ContentType::cases(), 'value')),
new ContentTypeMatchesPostPlatform,
],
...PostPlatformMetaRules::rules(),
'scheduled_at' => [
'nullable',
'date',
Rule::when(
$this->input('status') === Status::Scheduled->value,
['after:now']
),
],
'scheduled_at' => PostStatusRules::scheduledAtRules($this->route('post'), $status),
'label_ids' => ['sometimes', 'array'],
'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)],
];
Expand All @@ -71,7 +67,11 @@ public function rules(): array
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
if (! in_array($this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true)) {
if (! in_array(
PostStatusRules::normalizeStatus($this->input('status')),
[Status::Scheduled->value, Status::Publishing->value],
true,
)) {
return;
}

Expand All @@ -97,12 +97,8 @@ public function withValidator(Validator $validator): void
*/
private function addMediaCompatibilityErrors(Validator $validator): void
{
$routePost = $this->route('post');
$post = $routePost instanceof Post ? $routePost : Post::find($routePost);

if (! $post) {
return;
}
/** @var Post $post */
$post = $this->route('post');

$media = $this->has('media') ? (array) $this->input('media', []) : (array) ($post->media ?? []);

Expand All @@ -126,11 +122,11 @@ private function resolveSelectedPlatforms(): Collection
return collect();
}

/** @var Post $post */
$post = $this->route('post');
$postId = $post instanceof Post ? $post->id : $post;

return PostPlatform::query()
->where('post_id', $postId)
->where('post_id', $post->id)
->whereIn('id', $ids)
->pluck('platform', 'id');
}
Expand Down
19 changes: 7 additions & 12 deletions app/Http/Requests/App/Post/UpdatePostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use App\Rules\ContentTypeCompatibleWithMedia;
use App\Support\PostMediaRules;
use App\Support\PostPlatformMetaRules;
use App\Support\PostStatusRules;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
Expand All @@ -25,8 +26,10 @@ public function authorize(): bool

public function rules(): array
{
$status = PostStatusRules::normalizeStatus($this->input('status'));

$enforcesMediaCompatibility = in_array(
$this->input('status'),
$status,
[Status::Scheduled->value, Status::Publishing->value],
true,
);
Expand All @@ -43,15 +46,7 @@ public function rules(): array
),
],
...PostMediaRules::rules(hosted: true),
'scheduled_at' => [
'sometimes',
'nullable',
'date',
Rule::when(
$this->input('status') === Status::Scheduled->value,
['after:now']
),
],
'scheduled_at' => PostStatusRules::scheduledAtRules($this->route('post'), $status),
'platforms' => ['sometimes', 'array'],
'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id)],
'platforms.*.content_type' => [
Expand All @@ -68,7 +63,7 @@ public function rules(): array

public function withValidator(Validator $validator): void
{
$validator->after(function ($validator): void {
$validator->after(function (Validator $validator): void {
if (! $this->isPublishingOrScheduling()) {
return;
}
Expand All @@ -92,7 +87,7 @@ public function withValidator(Validator $validator): void
private function isPublishingOrScheduling(): bool
{
return in_array(
$this->input('status'),
PostStatusRules::normalizeStatus($this->input('status')),
[Status::Scheduled->value, Status::Publishing->value],
true,
);
Expand Down
2 changes: 1 addition & 1 deletion app/Mcp/Tools/Post/CreatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ 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('ISO 8601 datetime in the future (e.g. 2026-05-10T15:30:00Z). Defaults to today at 09:00 UTC.'),
'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.'),
'label_ids' => $schema->array()
->items($schema->string())
->description('Workspace label IDs to attach to the post.'),
Expand Down
8 changes: 5 additions & 3 deletions app/Mcp/Tools/Post/UpdatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ public function handle(Request $request): Response|ResponseFactory
return Response::error('Post not found.');
}

$status = PostStatusRules::normalizeStatus(data_get($request->all(), 'status'));

$validated = $request->validate([
'post_id' => ['required', 'uuid'],
'content' => ['nullable', 'string', 'max:10000'],
'scheduled_at' => ['nullable', 'date', 'after:now'],
'scheduled_at' => PostStatusRules::scheduledAtRules($post, $status),
'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 All @@ -63,7 +65,7 @@ public function handle(Request $request): Response|ResponseFactory
// here, or stored) against the post's stored media — the tool can't change
// media, so a misconfigured post can't be scheduled even without resubmitting
// content_type. Mirrors the public API's withValidator check.
if (data_get($validated, 'status') === Status::Scheduled->value) {
if ($status === Status::Scheduled->value) {
$errors = ContentTypeCompatibleWithMedia::errorsFor(
ContentTypeCompatibleWithMedia::entriesForUpdate($post, data_get($validated, 'platforms')),
(array) ($post->media ?? []),
Expand Down Expand Up @@ -94,7 +96,7 @@ public function schema(JsonSchema $schema): array
return [
'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('ISO 8601 datetime in the future. Required if status is "scheduled".'),
'scheduled_at' => $schema->string()->description('Future ISO 8601 datetime. Required for status "scheduled" unless the post already has a future schedule.'),
'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
42 changes: 42 additions & 0 deletions app/Support/PostStatusRules.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@

use App\Enums\Post\Status as PostStatus;
use App\Models\Post;
use Illuminate\Validation\Rule;

/**
* Shared post status helpers — edit/delete gates plus the `scheduled_at`
* update contract used by web, API, and MCP so those entry points cannot drift.
*/
class PostStatusRules
{
private const EDIT_BLOCKED_MESSAGE_KEY = 'posts.cannot_edit_finalized';
Expand Down Expand Up @@ -48,4 +53,41 @@ public static function editBlockedMessage(): string
{
return __(self::EDIT_BLOCKED_MESSAGE_KEY);
}

public static function normalizeStatus(mixed $status): ?string
{
return is_string($status) ? $status : null;
}

/**
* True when status is scheduled and the post has no future schedule to reuse.
*/
public static function requiresExplicitSchedule(?Post $post, ?string $status): bool
{
if ($status !== PostStatus::Scheduled->value) {
return false;
}

$existing = $post?->scheduled_at;

return $existing === null || $existing->isPast();
}

/**
* Validation rules for `scheduled_at` on post update (web, API, MCP).
*
* @return list<mixed>
*/
public static function scheduledAtRules(?Post $post, ?string $status): array
{
return [
Rule::requiredIf(fn (): bool => self::requiresExplicitSchedule($post, $status)),
'nullable',
'date',
Rule::when(
$status === PostStatus::Scheduled->value,
['after:now'],
),
];
}
}
Loading