Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
3f6032c
fix(facebook): empty-message rejection + state consistency + no re-pu…
paulocastellano May 15, 2026
27f27b6
fix(mcp): publish-post-tool blocks terminal states, not only Published
paulocastellano May 19, 2026
edec58a
refactor(post): remove now-dead PostAction::AlreadyPublished
paulocastellano May 19, 2026
d8f836a
chore(i18n): drop orphan cannot_edit_published key + gitignore compil…
paulocastellano May 19, 2026
0c955e8
test(post): cover all 4 terminal statuses on MCP update + API update
paulocastellano May 19, 2026
99d1770
fix(post): edit redirect loop + frontend validations
paulocastellano May 19, 2026
2902506
refactor(posts): replace status string literals with PostStatus enum
paulocastellano May 19, 2026
31db726
fix(posts): align Index/Calendar routing with Failed→show redirect
paulocastellano May 19, 2026
e4f8833
fix(posts): allow deleting Failed posts + misc terminal-state polish
paulocastellano May 19, 2026
cad89a0
revert(posts): drop redundant router.visit in Edit.vue Echo handler
paulocastellano May 19, 2026
2584257
refactor(posts): extract Edit.vue validation logic into usePostCompli…
paulocastellano May 19, 2026
3580d2e
refactor(posts): flatten togglePlatform with snapToCompatibleVariant …
paulocastellano May 19, 2026
1d61904
refactor(posts): collapse TikTok/Pinterest meta rules into PLATFORM_M…
paulocastellano May 19, 2026
df35d1b
feat(posts): add validation message for past date selection in PickTi…
paulocastellano May 19, 2026
3505135
refactor(posts): encode scheduled_at for API via date helper
paulocastellano May 19, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/bootstrap/ssr
/node_modules
/public/build
/lang/php_*.json
/public/hot
/public/storage
/storage/*.key
Expand Down
6 changes: 4 additions & 2 deletions app/Actions/Post/UpdatePost.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ class UpdatePost
*/
public static function execute(Workspace $workspace, Post $post, array $data): array
{
if ($post->status === PostStatus::Published) {
return ['post' => $post, 'action' => PostAction::AlreadyPublished];
$terminalStatuses = [PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Failed, PostStatus::Publishing];

if (in_array($post->status, $terminalStatuses, true)) {
return ['post' => $post, 'action' => PostAction::Finalized];
}

$scheduledAt = $post->scheduled_at;
Expand Down
2 changes: 1 addition & 1 deletion app/Enums/Post/Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

enum Action: string
{
case AlreadyPublished = 'already_published';
case Finalized = 'finalized';
case Publishing = 'publishing';
case Scheduled = 'scheduled';
}
4 changes: 2 additions & 2 deletions app/Http/Controllers/Api/PostController.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso

$result = UpdatePost::execute($request->user()->currentWorkspace, $post, $request->validated());

if (data_get($result, 'action') === PostAction::AlreadyPublished) {
if (data_get($result, 'action') === PostAction::Finalized) {
return response()->json(
['message' => 'Cannot edit a published post.'],
['message' => 'Cannot edit a post in a terminal state.'],
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
Expand Down
8 changes: 4 additions & 4 deletions app/Http/Controllers/App/PostController.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ public function show(Request $request, Post $post): Response|RedirectResponse

$this->authorize('view', $post);

if (in_array($post->status, [PostStatus::Draft, PostStatus::Scheduled, PostStatus::Failed], true)) {
if (in_array($post->status, [PostStatus::Draft, PostStatus::Scheduled], true)) {
return redirect()->route('app.posts.edit', $post);
}

Expand All @@ -219,7 +219,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse

$this->authorize('update', $post);

if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished], true)) {
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Failed], true)) {
return redirect()->route('app.posts.show', $post);
}

Expand Down Expand Up @@ -282,8 +282,8 @@ public function update(UpdatePostRequest $request, Post $post): RedirectResponse

$action = data_get($result, 'action');

if ($action === PostAction::AlreadyPublished) {
session()->flash('flash.banner', __('posts.flash.cannot_edit_published'));
if ($action === PostAction::Finalized) {
session()->flash('flash.banner', __('posts.flash.cannot_edit_finalized'));
session()->flash('flash.bannerStyle', 'danger');

return back();
Expand Down
4 changes: 2 additions & 2 deletions app/Mcp/Tools/Post/PublishPostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ public function handle(Request $request): Response|ResponseFactory
'scheduled_at' => $scheduledAt,
]);

if (data_get($result, 'action') === PostAction::AlreadyPublished) {
return Response::error('Post is already published.');
if (data_get($result, 'action') === PostAction::Finalized) {
return Response::error('Post is already published or in a terminal state.');
}

/** @var Post $updated */
Expand Down
4 changes: 2 additions & 2 deletions app/Mcp/Tools/Post/UpdatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ public function handle(Request $request): Response|ResponseFactory

$result = UpdatePost::execute($workspace, $post, $payload);

if (data_get($result, 'action') === PostAction::AlreadyPublished) {
return Response::error('Cannot edit a published post.');
if (data_get($result, 'action') === PostAction::Finalized) {
return Response::error('Cannot edit a post in a terminal state.');
}

/** @var Post $updated */
Expand Down
4 changes: 4 additions & 0 deletions app/Models/PostPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ public function markAsPublished(string $platformPostId, ?string $platformUrl = n
'platform_post_id' => $platformPostId,
'platform_url' => $platformUrl,
'published_at' => $now,
'error_message' => null,
'error_context' => null,
]);

$this->socialAccount?->update(['last_used_at' => $now]);
Expand All @@ -113,6 +115,8 @@ public function markAsFailed(string $errorMessage, ?array $errorContext = null):
'status' => Status::Failed,
'error_message' => $errorMessage,
'error_context' => $errorContext,
'platform_post_id' => null,
'platform_url' => null,
]);
}
}
41 changes: 29 additions & 12 deletions app/Services/Social/FacebookPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte
{
// Text only post
if ($media->isEmpty()) {
if (empty($content)) {
if ($content === null || $content === '') {
throw new \Exception('Facebook text posts require content. Please add text to your post.');
}

Expand Down Expand Up @@ -102,11 +102,16 @@ private function publishTextPost(string $pageId, string $accessToken, string $co

private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media): array
{
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
'message' => $content,
$payload = [
'url' => $media->url,
'access_token' => $accessToken,
]);
];

if ($content !== null && $content !== '') {
$payload['message'] = $content;
}

$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload);

if ($response->failed()) {
Log::error('Facebook single image post failed', [
Expand Down Expand Up @@ -159,10 +164,13 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str

// Create the post with attached media
$postData = [
'message' => $content,
'access_token' => $accessToken,
];

if ($content !== null && $content !== '') {
$postData['message'] = $content;
}

foreach ($attachedMedia as $index => $media) {
$postData["attached_media[{$index}]"] = json_encode($media);
}
Expand All @@ -188,12 +196,16 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str

private function publishVideoPost(string $pageId, string $accessToken, ?string $content, $media): array
{
// Use resumable upload for videos
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/videos", [
'description' => $content,
$payload = [
'file_url' => $media->url,
'access_token' => $accessToken,
]);
];

if ($content !== null && $content !== '') {
$payload['description'] = $content;
}

$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/videos", $payload);

if ($response->failed()) {
Log::error('Facebook video post failed', [
Expand Down Expand Up @@ -287,13 +299,18 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte
}

// Phase 3 (finish) — publish the reel.
$finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [
$finishPayload = [
'upload_phase' => 'finish',
'video_id' => $videoId,
'video_state' => 'PUBLISHED',
'description' => $content,
'access_token' => $accessToken,
]);
];

if ($content !== null && $content !== '') {
$finishPayload['description'] = $content;
}

$finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", $finishPayload);

if ($finishResponse->failed()) {
$this->handleApiError($finishResponse);
Expand Down
4 changes: 3 additions & 1 deletion lang/en/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@
'no_labels' => 'No labels created yet',
'schedule' => 'Schedule',
'pick_time' => 'Pick time',
'pick_time_past' => 'Pick a future date and time.',
'post_now' => 'Post now',
'time' => 'Time',
'cancel' => 'Cancel',
Expand Down Expand Up @@ -270,6 +271,7 @@
'platform_status' => 'Platform status',
'compliance_incomplete' => 'Some platform settings are incomplete or incompatible with the attached media.',
'compliance' => [
'requires_content_or_media' => 'Add text or media to publish.',
'requires_media' => 'Add an image or video to publish here.',
'too_many_files' => 'Only :max file(s) allowed for this format.',
'too_few_files' => 'Add at least :min files for this format.',
Expand Down Expand Up @@ -475,7 +477,7 @@
'scheduled' => 'Post scheduled successfully!',
'deleted' => 'Post deleted successfully!',
'duplicated' => 'Post duplicated as a draft.',
'cannot_edit_published' => 'Published posts cannot be edited.',
'cannot_edit_finalized' => 'This post has already been processed and cannot be re-published. Duplicate it to try again.',
'cannot_delete_published' => 'Published posts cannot be deleted.',
'connect_first' => 'Connect at least one social network before creating a post.',
],
Expand Down
4 changes: 3 additions & 1 deletion lang/es/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@
'organize' => 'Organizar',
'no_labels' => 'Todavía no hay etiquetas creadas',
'pick_time' => 'Elegir hora',
'pick_time_past' => 'Elige una fecha y hora en el futuro.',
'post_now' => 'Publicar ahora',
'time' => 'Hora',
'cancel' => 'Cancelar',
Expand All @@ -270,6 +271,7 @@
'platform_status' => 'Estado de la plataforma',
'compliance_incomplete' => 'Algunas configuraciones de plataforma están incompletas o son incompatibles con los medios adjuntos.',
'compliance' => [
'requires_content_or_media' => 'Agrega texto o multimedia para publicar.',
'requires_media' => 'Agrega una imagen o video para publicar aquí.',
'too_many_files' => 'Solo se permiten :max archivo(s) en este formato.',
'too_few_files' => 'Agrega al menos :min archivos para este formato.',
Expand Down Expand Up @@ -475,7 +477,7 @@
'scheduled' => '¡Post programado correctamente!',
'deleted' => '¡Post eliminado correctamente!',
'duplicated' => 'Post duplicado como borrador.',
'cannot_edit_published' => 'Los posts publicados no se pueden editar.',
'cannot_edit_finalized' => 'Este post ya fue procesado y no puede republicarse. Duplícalo para intentar de nuevo.',
'cannot_delete_published' => 'Los posts publicados no se pueden eliminar.',
'connect_first' => 'Conecta al menos una red social antes de crear un post.',
],
Expand Down
1 change: 0 additions & 1 deletion lang/php_en.json

This file was deleted.

1 change: 0 additions & 1 deletion lang/php_es.json

This file was deleted.

1 change: 0 additions & 1 deletion lang/php_pt-BR.json

This file was deleted.

4 changes: 3 additions & 1 deletion lang/pt-BR/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@
'organize' => 'Organizar',
'no_labels' => 'Nenhuma etiqueta criada ainda',
'pick_time' => 'Escolher horário',
'pick_time_past' => 'Escolha uma data e hora no futuro.',
'post_now' => 'Publicar agora',
'time' => 'Horário',
'cancel' => 'Cancelar',
Expand All @@ -270,6 +271,7 @@
'platform_status' => 'Status da plataforma',
'compliance_incomplete' => 'Algumas configurações de plataforma estão incompletas ou incompatíveis com a mídia anexada.',
'compliance' => [
'requires_content_or_media' => 'Adicione texto ou mídia para publicar.',
'requires_media' => 'Adicione uma imagem ou vídeo para publicar aqui.',
'too_many_files' => 'Apenas :max arquivo(s) permitido(s) para este formato.',
'too_few_files' => 'Adicione pelo menos :min arquivos para este formato.',
Expand Down Expand Up @@ -475,7 +477,7 @@
'scheduled' => 'Post agendado com sucesso!',
'deleted' => 'Post excluído com sucesso!',
'duplicated' => 'Post duplicado como rascunho.',
'cannot_edit_published' => 'Posts publicados não podem ser editados.',
'cannot_edit_finalized' => 'Este post já foi processado e não pode ser republicado. Duplique-o para tentar de novo.',
'cannot_delete_published' => 'Posts publicados não podem ser excluídos.',
'connect_first' => 'Conecte pelo menos uma rede social antes de criar um post.',
],
Expand Down
8 changes: 7 additions & 1 deletion resources/js/components/posts/PickTimePopover.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,14 @@ const buildDateTime = (): string => {
return `${dateStr}T${selectedHour.value}:${selectedMinute.value}:00`;
};

const isPastDateTime = computed(() => dayjs(buildDateTime()).isBefore(dayjs()));

const cancel = () => {
open.value = false;
};

const confirm = () => {
if (isPastDateTime.value) return;
const value = buildDateTime();
emit('update:modelValue', value);
emit('confirm', value);
Expand Down Expand Up @@ -112,6 +115,9 @@ const remove = () => {
</Select>
<span v-if="timezoneAbbr" class="ml-1 text-xs text-muted-foreground">{{ timezoneAbbr }}</span>
</div>
<p v-if="isPastDateTime" class="mt-2 text-xs font-semibold text-rose-700">
{{ $t('posts.edit.pick_time_past') }}
</p>
</div>

<div class="flex items-center justify-between gap-2 border-t p-3">
Expand All @@ -126,7 +132,7 @@ const remove = () => {
{{ $t('posts.edit.unschedule') }}
</Button>
<Button v-else type="button" variant="ghost" size="sm" @click="cancel">{{ $t('posts.edit.cancel') }}</Button>
<Button type="button" size="sm" @click="confirm">{{ $t('posts.edit.pick_time') }}</Button>
<Button type="button" size="sm" :disabled="isPastDateTime" @click="confirm">{{ $t('posts.edit.pick_time') }}</Button>
</div>
</DialogContent>
</Dialog>
Expand Down
7 changes: 3 additions & 4 deletions resources/js/components/posts/editor/PinterestSettings.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';

Expand All @@ -16,6 +15,7 @@ import {
ComboboxTrigger,
} from '@/components/ui/combobox';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
import type { PinterestBoard } from '@/types';
Expand Down Expand Up @@ -79,11 +79,10 @@ const selectedBoard = computed<BoardOption | undefined>({
// (`platforms.0.meta.board_id`). Suffix match avoids threading the index
// through props. Cleared as soon as a board is picked locally so the user
// doesn't see a stale error after fixing the issue.
const page = usePage();
const errors = usePageErrors();
const boardError = computed<string | undefined>(() => {
if (props.meta?.board_id) return undefined;
const errors = (page.props.errors as Record<string, string> | undefined) ?? {};
return Object.entries(errors).find(([key]) => key.endsWith('.meta.board_id'))?.[1];
return Object.entries(errors.value).find(([key]) => key.endsWith('.meta.board_id'))?.[1];
});
</script>

Expand Down
Loading
Loading