Merge pull request #41 from trypostit/fix/facebook-empty-message

fix(facebook): empty-message rejection + state consistency + no re-publish on terminal
This commit is contained in:
Paulo Castellano 2026-05-19 17:00:16 -03:00 committed by GitHub
commit ac1a362f2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 914 additions and 344 deletions

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
/bootstrap/ssr
/node_modules
/public/build
/lang/php_*.json
/public/hot
/public/storage
/storage/*.key

View file

@ -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;

View file

@ -6,7 +6,7 @@
enum Action: string
{
case AlreadyPublished = 'already_published';
case Finalized = 'finalized';
case Publishing = 'publishing';
case Scheduled = 'scheduled';
}

View file

@ -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
);
}

View file

@ -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);
}
@ -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);
}
@ -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();

View file

@ -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 */

View file

@ -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 */

View file

@ -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]);
@ -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,
]);
}
}

View file

@ -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.');
}
@ -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', [
@ -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);
}
@ -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', [
@ -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);

View file

@ -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',
@ -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.',
@ -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.',
],

View file

@ -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',
@ -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.',
@ -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.',
],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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',
@ -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.',
@ -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.',
],

View file

@ -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);
@ -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">
@ -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>

View file

@ -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';
@ -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';
@ -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>

View file

@ -2,9 +2,12 @@
import { IconCalendar, IconCircleCheck, IconLoader2, IconTrash } from '@tabler/icons-vue';
import { computed } from 'vue';
import InputError from '@/components/InputError.vue';
import PickTimePopover from '@/components/posts/PickTimePopover.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { usePageErrors } from '@/composables/usePageErrors';
import { PostStatus } from '@/types/post';
interface Props {
post: { status: string };
@ -27,9 +30,15 @@ const emit = defineEmits<{
(e: 'submit', status: string): void;
}>();
const isReadOnly = computed(() => ['publishing', 'published', 'partially_published'].includes(props.post.status));
const isScheduled = computed(() => props.post.status === 'scheduled');
const isPublished = computed(() => ['published', 'partially_published'].includes(props.post.status));
const READONLY_STATUSES: readonly string[] = [PostStatus.Publishing, PostStatus.Published, PostStatus.PartiallyPublished, PostStatus.Failed];
const PUBLISHED_STATUSES: readonly string[] = [PostStatus.Published, PostStatus.PartiallyPublished];
const isReadOnly = computed(() => READONLY_STATUSES.includes(props.post.status));
const isScheduled = computed(() => props.post.status === PostStatus.Scheduled);
const isPublished = computed(() => PUBLISHED_STATUSES.includes(props.post.status));
const errors = usePageErrors();
const scheduledAtError = computed(() => errors.value.scheduled_at);
</script>
<template>
@ -82,49 +91,68 @@ const isPublished = computed(() => ['published', 'partially_published'].includes
</span>
</div>
<div v-if="!isReadOnly" class="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="icon"
class="bg-rose-100 hover:bg-rose-200"
:disabled="isSaving || isSubmitting"
@click="emit('delete')"
>
<IconTrash class="size-4 text-rose-700" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.delete') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<div v-if="!isReadOnly" class="flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="icon"
class="bg-rose-100 hover:bg-rose-200"
:disabled="isSaving || isSubmitting"
@click="emit('delete')"
>
<IconTrash class="size-4 text-rose-700" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.delete') }}</TooltipContent>
</Tooltip>
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="outline"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
>
<IconCalendar class="size-4" />
{{ pickTimeLabel }}
</Button>
</PickTimePopover>
<Tooltip>
<TooltipTrigger as-child>
<span tabindex="0">
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="outline"
:disabled="isPostActionDisabled"
>
<IconCalendar class="size-4" />
{{ pickTimeLabel }}
</Button>
</PickTimePopover>
</span>
</TooltipTrigger>
<TooltipContent v-if="postActionTooltip" class="max-w-xs whitespace-pre-line">
{{ postActionTooltip }}
</TooltipContent>
</Tooltip>
<Button
type="button"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
@click="emit('submit', hasPickedTime ? 'scheduled' : 'publishing')"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
</Button>
<Tooltip>
<TooltipTrigger as-child>
<span tabindex="0">
<Button
type="button"
:disabled="isPostActionDisabled"
@click="emit('submit', hasPickedTime ? PostStatus.Scheduled : PostStatus.Publishing)"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
</Button>
</span>
</TooltipTrigger>
<TooltipContent v-if="postActionTooltip" class="max-w-xs whitespace-pre-line">
{{ postActionTooltip }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<InputError :message="scheduledAtError" />
</div>
</template>
</header>

View file

@ -11,9 +11,11 @@ import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue';
import { Avatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import { Platform } from '@/enums/platform';
import type { PinterestBoard } from '@/types';
import { PostPlatformStatus } from '@/types/post';
interface SocialAccount {
id: string;
@ -151,6 +153,24 @@ const getPlatformDisplayName = (pp: PostPlatform): string =>
const getPlatformAvatar = (pp: PostPlatform): string | null =>
pp.social_account?.avatar_url ?? pp.platform_avatar ?? null;
// Map pp.id → submit-array index, matching what Edit.vue sends as `platforms[i]`.
// Backend validation errors are keyed `platforms.{i}.content_type`, so we use this
// to surface the right error under each platform's variant picker.
const errors = usePageErrors();
const submitIndexByPpId = computed<Record<string, number>>(() => {
const map: Record<string, number> = {};
props.postPlatforms
.filter((pp) => props.selectedPlatformIds.includes(pp.id))
.forEach((pp, index) => { map[pp.id] = index; });
return map;
});
const contentTypeErrorFor = (pp: PostPlatform): string | undefined => {
const index = submitIndexByPpId.value[pp.id];
if (index === undefined) return undefined;
return errors.value[`platforms.${index}.content_type`];
};
</script>
<template>
@ -199,10 +219,10 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
>
<IconAlertCircle class="h-2.5 w-2.5" />
</Badge>
<Badge v-else-if="pp.status === 'published'" variant="success" class="absolute -top-1 -right-1 h-4 w-4 p-0">
<Badge v-else-if="pp.status === PostPlatformStatus.Published" variant="success" class="absolute -top-1 -right-1 h-4 w-4 p-0">
<IconCircleCheck class="h-2.5 w-2.5" />
</Badge>
<Badge v-else-if="pp.status === 'failed'" variant="destructive" class="absolute -top-1 -right-1 h-4 w-4 p-0 text-[9px]">!</Badge>
<Badge v-else-if="pp.status === PostPlatformStatus.Failed" variant="destructive" class="absolute -top-1 -right-1 h-4 w-4 p-0 text-[9px]">!</Badge>
</div>
<span
class="line-clamp-2 text-center text-xs leading-tight"
@ -226,7 +246,7 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
</div>
</div>
<div v-if="postPlatforms.some(pp => pp.status !== 'pending')">
<div v-if="postPlatforms.some(pp => pp.status !== PostPlatformStatus.Pending)">
<p class="mb-2 text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('posts.edit.platform_status') }}
</p>
@ -243,12 +263,12 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
<span class="text-sm font-bold text-foreground">{{ getPlatformDisplayName(pp) }}</span>
</div>
<div class="flex items-center gap-2">
<Badge v-if="pp.status === 'published'" variant="success">{{ $t('posts.edit.status.published') }}</Badge>
<Badge v-else-if="pp.status === 'publishing'" variant="warning">
<Badge v-if="pp.status === PostPlatformStatus.Published" variant="success">{{ $t('posts.edit.status.published') }}</Badge>
<Badge v-else-if="pp.status === PostPlatformStatus.Publishing" variant="warning">
<IconLoader2 class="size-3 animate-spin" />
{{ $t('posts.edit.status.publishing') }}
</Badge>
<Badge v-else-if="pp.status === 'failed'" variant="destructive">{{ $t('posts.edit.status.failed') }}</Badge>
<Badge v-else-if="pp.status === PostPlatformStatus.Failed" variant="destructive">{{ $t('posts.edit.status.failed') }}</Badge>
<a
v-if="pp.platform_url"
:href="pp.platform_url"
@ -273,6 +293,7 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
:creator-info="getCreatorInfo(pp)"
:video-duration-sec="videoDurationSec"
:content-type="platformContentTypes[pp.id] ?? ''"
:content-type-error="contentTypeErrorFor(pp)"
:meta="platformMeta[pp.id] ?? {}"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"

View file

@ -4,6 +4,7 @@ import { trans } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import InputError from '@/components/InputError.vue';
import { Avatar } from '@/components/ui/avatar';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
@ -42,6 +43,7 @@ interface Props {
creatorInfo?: CreatorInfo | null;
videoDurationSec?: number | null;
contentType: string;
contentTypeError?: string;
meta: Record<string, any>;
disabled?: boolean;
}
@ -49,6 +51,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
creatorInfo: null,
videoDurationSec: null,
contentTypeError: undefined,
disabled: false,
});
@ -232,6 +235,7 @@ watch(
{{ $t(variant.labelKey) }}
</button>
</div>
<InputError :message="contentTypeError" />
</div>
<!-- Creator identity -->

View file

@ -87,6 +87,10 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
acceptsGif: false,
// maxVideoDurationSec is enforced dynamically via creator_info
},
tiktok_photo: {
maxFiles: 35, minFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
},
// YouTube
youtube_short: {

View file

@ -0,0 +1,14 @@
import { usePage } from '@inertiajs/vue3';
import { computed, type ComputedRef } from 'vue';
/**
* Reactive access to Inertia's page-level validation errors, keyed by field
* path (e.g. `scheduled_at`, `platforms.0.content_type`). Returns an empty
* object when no errors are present so consumers can index without null
* checks. Cast to `Record<string, string>` because the app only ever flashes
* single string messages (no nested error bags).
*/
export const usePageErrors = (): ComputedRef<Record<string, string>> => {
const page = usePage();
return computed(() => (page.props.errors ?? {}) as Record<string, string>);
};

View file

@ -0,0 +1,236 @@
import { trans } from 'laravel-vue-i18n';
import { computed, type ComputedRef, type Ref } from 'vue';
import { getMediaItemIssue, type MediaItem } from '@/composables/useMedia';
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import { getPlatformLabel } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
import { Platform } from '@/enums/platform';
export interface CompliancePostPlatform {
id: string;
platform: string;
platform_name: string | null;
social_account_id: string | null;
content_type: string | null;
}
export interface CompliancePost {
post_platforms: CompliancePostPlatform[];
}
export const PLATFORM_VARIANTS: Record<string, string[]> = {
[Platform.Facebook]: [ContentType.FacebookPost, ContentType.FacebookReel, ContentType.FacebookStory],
[Platform.Instagram]: [ContentType.InstagramFeed, ContentType.InstagramReel, ContentType.InstagramStory],
[Platform.InstagramFacebook]: [ContentType.InstagramFeed, ContentType.InstagramReel, ContentType.InstagramStory],
[Platform.LinkedIn]: [ContentType.LinkedInPost, ContentType.LinkedInCarousel],
[Platform.LinkedInPage]: [ContentType.LinkedInPagePost, ContentType.LinkedInPageCarousel],
[Platform.TikTok]: [ContentType.TikTokVideo, ContentType.TikTokPhoto],
[Platform.Pinterest]: [ContentType.PinterestPin, ContentType.PinterestVideoPin, ContentType.PinterestCarousel],
};
type MetaRule = (meta: Record<string, any>) => { valid: boolean; tooltipKey: string | null };
// Platforms whose `meta` blob has publish-time requirements. `valid` gates
// scheduling; `tooltipKey` (when set) surfaces a platform-specific message
// — null means "blocks the publish but no dedicated message, fall through
// to the generic incomplete tooltip".
const PLATFORM_META_RULES: Record<string, MetaRule> = {
[Platform.TikTok]: (meta) => {
const disclosureIncomplete = Boolean(meta.disclose)
&& !meta.brand_organic_toggle
&& !meta.brand_content_toggle;
const privacyLevelMissing = !meta.privacy_level;
return {
valid: !disclosureIncomplete && !privacyLevelMissing,
tooltipKey: disclosureIncomplete ? 'posts.form.tiktok.compliance_incomplete' : null,
};
},
[Platform.Pinterest]: (meta) => ({
valid: Boolean(meta.board_id),
tooltipKey: meta.board_id ? null : 'posts.form.pinterest.board_required',
}),
};
export const getMediaIncompatibilityReason = (
contentType: string,
mediaItems: MediaItem[],
): string | null => {
const rules = getMediaRulesForContentType(contentType);
const videos = mediaItems.filter((m) => m.type === 'video' || m.mime_type?.startsWith('video/'));
const images = mediaItems.filter((m) => m.type === 'image' || m.mime_type?.startsWith('image/'));
const gifs = mediaItems.filter((m) => m.mime_type === 'image/gif');
const total = mediaItems.length;
if (rules.requiresMedia && total === 0) return trans('posts.edit.compliance.requires_media');
if (!rules.acceptVideos && videos.length > 0) return trans('posts.edit.compliance.no_videos');
if (!rules.acceptImages && images.length > 0) return trans('posts.edit.compliance.no_images');
if (!rules.acceptsGif && gifs.length > 0) return trans('posts.edit.compliance.no_gifs');
if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: String(rules.maxFiles) });
if (rules.minFiles && total < rules.minFiles) return trans('posts.edit.compliance.too_few_files', { min: String(rules.minFiles) });
for (const m of mediaItems) {
const isVideo = m.type === 'video' || m.mime_type?.startsWith('video/');
const size = m.size ?? 0;
const duration = m.meta?.duration ?? 0;
const width = m.meta?.width ?? 0;
const height = m.meta?.height ?? 0;
if (isVideo) {
if (rules.maxVideoBytes && size > 0 && size > rules.maxVideoBytes) return trans('posts.edit.compliance.video_too_large');
if (rules.maxVideoDurationSec && duration > 0 && duration > rules.maxVideoDurationSec) {
return trans('posts.edit.compliance.video_too_long', { seconds: String(rules.maxVideoDurationSec) });
}
} else if (rules.maxImageBytes && size > 0 && size > rules.maxImageBytes) {
return trans('posts.edit.compliance.image_too_large');
}
if (width > 0 && height > 0 && (rules.aspectRatioMin || rules.aspectRatioMax)) {
const ratio = width / height;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) return trans('posts.edit.compliance.aspect_ratio_invalid');
if (rules.aspectRatioMax && ratio > rules.aspectRatioMax) return trans('posts.edit.compliance.aspect_ratio_invalid');
}
}
return null;
};
export const firstCompatibleVariant = (
platform: string,
mediaItems: MediaItem[],
): string | null => {
const variants = PLATFORM_VARIANTS[platform];
if (!variants) return null;
return variants.find((ct) => !getMediaIncompatibilityReason(ct, mediaItems)) ?? null;
};
interface UsePostComplianceOptions {
post: ComputedRef<CompliancePost>;
content: Ref<string>;
media: Ref<MediaItem[]>;
selectedPlatformIds: Ref<string[]>;
platformContentTypes: Ref<Record<string, string>>;
platformMeta: Ref<Record<string, Record<string, any>>>;
platformConfigs: Record<string, { maxContentLength?: number | null }>;
}
export const usePostCompliance = (opts: UsePostComplianceOptions) => {
const { post, content, media, selectedPlatformIds, platformContentTypes, platformMeta, platformConfigs } = opts;
const selectedPlatforms = computed(() => post.value.post_platforms.filter(
(pp) => selectedPlatformIds.value.includes(pp.id),
));
const platformLimits = computed(() => {
const seen = new Set<string>();
const result: { platform: string; maxLength: number }[] = [];
for (const pp of selectedPlatforms.value) {
if (seen.has(pp.platform)) continue;
const max = pp.social_account_id ? platformConfigs[pp.social_account_id]?.maxContentLength : null;
if (typeof max === 'number' && max > 0) {
seen.add(pp.platform);
result.push({ platform: pp.platform, maxLength: max });
}
}
return result;
});
const mediaIssues = computed<Record<string, { platform: string; reason: string }[]>>(() => {
const result: Record<string, { platform: string; reason: string }[]> = {};
for (const item of media.value) {
const issues: { platform: string; reason: string }[] = [];
const seen = new Set<string>();
for (const pp of selectedPlatforms.value) {
if (seen.has(pp.platform)) continue;
const contentType = platformContentTypes.value[pp.id] ?? pp.content_type ?? '';
const reason = getMediaItemIssue(item, contentType);
if (reason) {
seen.add(pp.platform);
issues.push({ platform: pp.platform, reason });
}
}
if (issues.length > 0) result[item.id] = issues;
}
return result;
});
const platformIssues = computed<Record<string, string>>(() => {
const issues: Record<string, string> = {};
for (const pp of post.value.post_platforms) {
const contentType = platformContentTypes.value[pp.id];
if (!contentType) {
issues[pp.id] = trans('posts.edit.compliance.no_content_type');
continue;
}
const reason = getMediaIncompatibilityReason(contentType, media.value);
if (!reason) continue;
const isSelected = selectedPlatformIds.value.includes(pp.id);
if (!isSelected && firstCompatibleVariant(pp.platform, media.value)) continue;
issues[pp.id] = reason;
}
return issues;
});
const platformMetaResults = computed(() => selectedPlatforms.value.map((pp) => {
const rule = PLATFORM_META_RULES[pp.platform];
if (!rule) return { valid: true, tooltipKey: null };
return rule(platformMeta.value[pp.id] ?? {});
}));
const hasContentOrMedia = computed(
() => content.value.trim().length > 0 || media.value.length > 0,
);
const contentLengthOverflows = computed(() => {
const len = content.value.length;
return platformLimits.value
.filter((p) => len > p.maxLength)
.map((p) => ({ platform: p.platform, limit: p.maxLength, over: len - p.maxLength }));
});
const canSchedule = computed(() => {
const mediaValid = selectedPlatformIds.value.every((id) => !platformIssues.value[id]);
const metaValid = platformMetaResults.value.every((r) => r.valid);
return mediaValid
&& metaValid
&& hasContentOrMedia.value
&& contentLengthOverflows.value.length === 0;
});
const postActionTooltip = computed(() => {
if (canSchedule.value) return '';
const mediaReasons = selectedPlatforms.value
.filter((pp) => platformIssues.value[pp.id])
.map((pp) => `${pp.platform_name ?? pp.platform}: ${platformIssues.value[pp.id]}`);
const lengthReasons = contentLengthOverflows.value.map((overflow) => trans('posts.form.content_exceeds_platform', {
platform: getPlatformLabel(overflow.platform),
limit: String(overflow.limit),
over: String(overflow.over),
}));
const combined = [...mediaReasons, ...lengthReasons].join('\n');
if (combined) return combined;
const metaTooltipKey = platformMetaResults.value.find((r) => r.tooltipKey)?.tooltipKey;
if (metaTooltipKey) return trans(metaTooltipKey);
if (!hasContentOrMedia.value) return trans('posts.edit.compliance.requires_content_or_media');
return trans('posts.edit.compliance_incomplete');
});
return {
platformLimits,
mediaIssues,
platformIssues,
canSchedule,
postActionTooltip,
};
};

View file

@ -165,6 +165,29 @@ export default {
return dayjs(date).format('YYYY-MM-DD');
},
/**
* Converte uma data UTC para o formato esperado por inputs HTML `datetime-local`
* (YYYY-MM-DDTHH:mm:00) no timezone do usuário.
* @param date - Data em UTC (ISO string) ou nulo
* @returns String no formato YYYY-MM-DDTHH:mm:00 ou string vazia quando não houver data
*/
formatUtcForDateTimeLocalInput(date: string | null | undefined): string {
if (!date) return '';
return dayjs.utc(date).tz(getUserTimezone()).format('YYYY-MM-DDTHH:mm:00');
},
/**
* Inverso de `formatUtcForDateTimeLocalInput`: recebe uma string vinda de um
* input `datetime-local` (no timezone do usuário) e devolve uma ISO 8601 em
* UTC pronta para enviar à API. Retorna `null` quando não houver data.
* @param date - String do input (YYYY-MM-DDTHH:mm[:ss]) ou nulo
* @returns ISO 8601 em UTC (ex.: 2026-05-19T19:40:00Z) ou null
*/
formatLocalDateTimeForApi(date: string | null | undefined): string | null {
if (!date) return null;
return dayjs.tz(date, getUserTimezone()).utc().format();
},
/**
* Formata minutos para formato legível
* @param minutes - Número de minutos

View file

@ -13,6 +13,7 @@ import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import { calendar } from '@/routes/app';
import { create as createPost, edit as editPost, show as showPost } from '@/routes/app/posts';
import { PostStatus } from '@/types/post';
interface PostPlatform {
id: string;
@ -230,8 +231,10 @@ const getStatusColor = (status: string): string => {
return colors[status] ?? colors.draft;
};
const EDITABLE_STATUSES: readonly string[] = [PostStatus.Draft, PostStatus.Scheduled];
const getPostUrl = (post: Post): string => {
return ['draft', 'scheduled', 'failed'].includes(post.status)
return EDITABLE_STATUSES.includes(post.status)
? editPost.url(post.id)
: showPost.url(post.id);
};

View file

@ -11,15 +11,18 @@ import PostEditorComposer from '@/components/posts/editor/PostEditorComposer.vue
import PostEditorHeader from '@/components/posts/editor/PostEditorHeader.vue';
import PostEditorSidebar from '@/components/posts/editor/PostEditorSidebar.vue';
import { usePostEcho } from '@/composables/echo/usePostEcho';
import { getMediaItemIssue } from '@/composables/useMedia';
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import { getPlatformLabel } from '@/composables/usePlatformLogo';
import {
firstCompatibleVariant,
getMediaIncompatibilityReason,
usePostCompliance,
} from '@/composables/usePostCompliance';
import date from '@/date';
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import { Platform } from '@/enums/platform';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy as destroyPost, update as updatePost } from '@/routes/app/posts';
import type { PinterestBoard } from '@/types';
import { PostStatus } from '@/types/post';
interface MediaItem {
id: string;
@ -97,10 +100,15 @@ const props = defineProps<{
}>();
const post = computed(() => props.post);
// Terminal states the user cannot recover from — delete, navigate, but never edit.
const isReadOnly = computed(() => ['publishing', 'published', 'partially_published'].includes(post.value.status));
const isPublishing = computed(() => post.value.status === 'publishing');
const isScheduled = computed(() => post.value.status === 'scheduled');
const READONLY_STATUSES: readonly string[] = [
PostStatus.Publishing,
PostStatus.Published,
PostStatus.PartiallyPublished,
PostStatus.Failed,
];
const isReadOnly = computed(() => READONLY_STATUSES.includes(post.value.status));
const isPublishing = computed(() => post.value.status === PostStatus.Publishing);
const isScheduled = computed(() => post.value.status === PostStatus.Scheduled);
// Locked states — terminal + scheduled. Field edits and auto-save suppressed;
// user must unschedule to re-enter draft and edit.
const isLocked = computed(() => isReadOnly.value || isScheduled.value);
@ -132,219 +140,25 @@ const updatePlatformContentType = (platformId: string, contentType: string) => {
platformContentTypes.value = { ...platformContentTypes.value, [platformId]: contentType };
};
const platformLimits = computed(() => {
const seen = new Set<string>();
const result: { platform: string; maxLength: number }[] = [];
for (const pp of post.value.post_platforms) {
if (! selectedPlatformIds.value.includes(pp.id)) continue;
if (seen.has(pp.platform)) continue;
const accountId = pp.social_account_id;
const max = accountId ? props.platformConfigs[accountId]?.maxContentLength : null;
if (typeof max === 'number' && max > 0) {
seen.add(pp.platform);
result.push({ platform: pp.platform, maxLength: max });
}
}
return result;
});
const mediaIssues = computed(() => {
const result: Record<string, { platform: string; reason: string }[]> = {};
for (const item of media.value) {
const issues: { platform: string; reason: string }[] = [];
const seen = new Set<string>();
for (const pp of post.value.post_platforms) {
if (! selectedPlatformIds.value.includes(pp.id)) continue;
if (seen.has(pp.platform)) continue;
const contentType = platformContentTypes.value[pp.id] ?? pp.content_type ?? '';
const reason = getMediaItemIssue(item, contentType);
if (reason) {
seen.add(pp.platform);
issues.push({ platform: pp.platform, reason });
}
}
if (issues.length > 0) result[item.id] = issues;
}
return result;
});
const getMediaIncompatibilityReason = (contentType: string, mediaItems: MediaItem[]): string | null => {
const rules = getMediaRulesForContentType(contentType);
const videos = mediaItems.filter((m) => m.type === 'video' || m.mime_type?.startsWith('video/'));
const images = mediaItems.filter((m) => m.type === 'image' || m.mime_type?.startsWith('image/'));
const gifs = mediaItems.filter((m) => m.mime_type === 'image/gif');
const total = mediaItems.length;
// Order matters: type-mismatch errors (image vs video vs gif) are more
// fundamental than count errors. Telling the user "YouTube doesn't accept
// images" is more actionable than "too many files" when they're mixing types.
if (rules.requiresMedia && total === 0) return trans('posts.edit.compliance.requires_media');
if (!rules.acceptVideos && videos.length > 0) return trans('posts.edit.compliance.no_videos');
if (!rules.acceptImages && images.length > 0) return trans('posts.edit.compliance.no_images');
if (!rules.acceptsGif && gifs.length > 0) return trans('posts.edit.compliance.no_gifs');
if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: String(rules.maxFiles) });
if (rules.minFiles && total < rules.minFiles) return trans('posts.edit.compliance.too_few_files', { min: String(rules.minFiles) });
for (const m of mediaItems) {
const isVideo = m.type === 'video' || m.mime_type?.startsWith('video/');
const size = m.size ?? 0;
const duration = m.meta?.duration ?? 0;
const width = m.meta?.width ?? 0;
const height = m.meta?.height ?? 0;
if (isVideo) {
if (rules.maxVideoBytes && size > 0 && size > rules.maxVideoBytes) return trans('posts.edit.compliance.video_too_large');
if (rules.maxVideoDurationSec && duration > 0 && duration > rules.maxVideoDurationSec) {
return trans('posts.edit.compliance.video_too_long', { seconds: String(rules.maxVideoDurationSec) });
}
} else if (rules.maxImageBytes && size > 0 && size > rules.maxImageBytes) {
return trans('posts.edit.compliance.image_too_large');
}
if (width > 0 && height > 0 && (rules.aspectRatioMin || rules.aspectRatioMax)) {
const ratio = width / height;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) return trans('posts.edit.compliance.aspect_ratio_invalid');
if (rules.aspectRatioMax && ratio > rules.aspectRatioMax) return trans('posts.edit.compliance.aspect_ratio_invalid');
}
}
return null;
};
// Platforms whose default content_type doesn't accept every media type,
// so the tile would otherwise block silently when the user attaches the
// "wrong" kind. List the variants that should be considered at the tile
// level — togglePlatform snaps content_type to whichever fits the media.
//
// Instagram/Facebook/LinkedIn are intentionally NOT here: their defaults
// already accept image and video, and their secondary variants (Reel,
// Carousel) are picked manually by the user. Including them would hide
// real picker errors (e.g. Reel + image incompatibility) at the tile level.
const PLATFORM_VARIANTS: Record<string, string[]> = {
[Platform.TikTok]: ['tiktok_video', 'tiktok_photo'],
[Platform.Pinterest]: ['pinterest_pin', 'pinterest_video_pin', 'pinterest_carousel'],
};
const firstCompatibleVariant = (platform: string, mediaItems: MediaItem[]): string | null => {
const variants = PLATFORM_VARIANTS[platform];
if (!variants) return null;
return variants.find((ct) => !getMediaIncompatibilityReason(ct, mediaItems)) ?? null;
};
const platformIssues = computed<Record<string, string>>(() => {
const issues: Record<string, string> = {};
for (const pp of post.value.post_platforms) {
const contentType = platformContentTypes.value[pp.id];
if (!contentType) {
issues[pp.id] = trans('posts.edit.compliance.no_content_type');
continue;
}
// For platforms that expose multiple content types via a variant picker,
// the tile is only blocked when no variant fits — togglePlatform will
// switch to a compatible variant on selection.
if (PLATFORM_VARIANTS[pp.platform]) {
if (!firstCompatibleVariant(pp.platform, media.value)) {
issues[pp.id] = getMediaIncompatibilityReason(contentType, media.value) ?? '';
}
continue;
}
const reason = getMediaIncompatibilityReason(contentType, media.value);
if (reason) issues[pp.id] = reason;
}
return issues;
});
const mediaCompliancePerPlatformValid = computed(
() => selectedPlatformIds.value.every((id) => !platformIssues.value[id]),
);
// TikTok compliance per docs:
// - privacy_level must be explicitly selected
// - if disclosure toggle is ON, at least one sub-toggle must be selected
const tiktokComplianceValid = computed(() => {
const tiktokPlatforms = post.value.post_platforms.filter(
(pp) => pp.platform === Platform.TikTok && selectedPlatformIds.value.includes(pp.id),
);
return tiktokPlatforms.every((pp) => {
const meta = platformMeta.value[pp.id] ?? {};
if (!meta.privacy_level) return false;
if (meta.disclose && !meta.brand_organic_toggle && !meta.brand_content_toggle) return false;
return true;
});
});
// Pinterest needs a board_id selected to publish (the API rejects without it).
const pinterestComplianceValid = computed(() => {
const pinterestPlatforms = post.value.post_platforms.filter(
(pp) => pp.platform === Platform.Pinterest && selectedPlatformIds.value.includes(pp.id),
);
return pinterestPlatforms.every((pp) => Boolean(platformMeta.value[pp.id]?.board_id));
});
const contentLengthOverflows = computed(() => {
const len = content.value.length;
return platformLimits.value
.filter((p) => len > p.maxLength)
.map((p) => ({ platform: p.platform, limit: p.maxLength, over: len - p.maxLength }));
});
const canSchedule = computed(
() => mediaCompliancePerPlatformValid.value
&& tiktokComplianceValid.value
&& pinterestComplianceValid.value
&& contentLengthOverflows.value.length === 0,
);
const postActionTooltip = computed(() => {
if (canSchedule.value) return '';
const mediaReasons = post.value.post_platforms
.filter((pp) => selectedPlatformIds.value.includes(pp.id) && platformIssues.value[pp.id])
.map((pp) => `${pp.platform_name ?? pp.platform}: ${platformIssues.value[pp.id]}`);
const lengthReasons = contentLengthOverflows.value.map((overflow) => trans('posts.form.content_exceeds_platform', {
platform: getPlatformLabel(overflow.platform),
limit: String(overflow.limit),
over: String(overflow.over),
}));
const reasons = [...mediaReasons, ...lengthReasons];
if (reasons.length > 0) return reasons.join('\n');
// No media issues — the only remaining blocker is TikTok compliance.
// Surface the exact TikTok-required tooltip when the disclosure toggle
// is on without a sub-selection (UX Guideline Point 3a). For other TikTok
// cases (e.g. missing privacy_level), fall back to the generic message.
const tiktokDisclosureIncomplete = post.value.post_platforms.some((pp) => {
if (pp.platform !== Platform.TikTok) return false;
if (!selectedPlatformIds.value.includes(pp.id)) return false;
const meta = platformMeta.value[pp.id] ?? {};
return Boolean(meta.disclose) && !meta.brand_organic_toggle && !meta.brand_content_toggle;
});
if (tiktokDisclosureIncomplete) {
return trans('posts.form.tiktok.compliance_incomplete');
}
if (!pinterestComplianceValid.value) {
return trans('posts.form.pinterest.board_required');
}
return trans('posts.edit.compliance_incomplete');
const {
platformLimits,
mediaIssues,
platformIssues,
canSchedule,
postActionTooltip,
} = usePostCompliance({
post,
content,
media,
selectedPlatformIds,
platformContentTypes,
platformMeta,
platformConfigs: props.platformConfigs,
});
// Schedule
const getLocalSchedule = () => {
if (!post.value.scheduled_at) return '';
return dayjs.utc(post.value.scheduled_at).local().format('YYYY-MM-DDTHH:mm:00');
};
const scheduledDateTime = ref(getLocalSchedule());
const hasPickedTime = ref(post.value.status === 'scheduled' && !! post.value.scheduled_at);
const scheduledDateTime = ref(date.formatUtcForDateTimeLocalInput(post.value.scheduled_at));
const hasPickedTime = ref(post.value.status === PostStatus.Scheduled && !! post.value.scheduled_at);
const pickTimeLabel = computed(() => {
if (! hasPickedTime.value || ! scheduledDateTime.value) {
@ -384,21 +198,28 @@ const activeTab = ref(initialTabFromQuery);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const editorSidebarRef = ref<InstanceType<typeof PostEditorSidebar> | null>(null);
const snapToCompatibleVariant = (platformId: string) => {
const pp = post.value.post_platforms.find((p) => p.id === platformId);
const current = platformContentTypes.value[platformId];
if (!pp || !current) return;
if (!getMediaIncompatibilityReason(current, media.value)) return;
const fallback = firstCompatibleVariant(pp.platform, media.value);
if (!fallback) return;
platformContentTypes.value = { ...platformContentTypes.value, [platformId]: fallback };
};
const togglePlatform = (platformId: string) => {
if (isLocked.value) return;
const index = selectedPlatformIds.value.indexOf(platformId);
if (index === -1) {
// For platforms with a variant picker, snap the post-platform's
// content_type to one that fits the current media before selection.
const pp = post.value.post_platforms.find((p) => p.id === platformId);
const variant = pp ? firstCompatibleVariant(pp.platform, media.value) : null;
if (variant && platformContentTypes.value[platformId] !== variant) {
platformContentTypes.value = { ...platformContentTypes.value, [platformId]: variant };
}
selectedPlatformIds.value.push(platformId);
} else {
selectedPlatformIds.value.splice(index, 1);
if (selectedPlatformIds.value.includes(platformId)) {
selectedPlatformIds.value = selectedPlatformIds.value.filter((id) => id !== platformId);
return;
}
snapToCompatibleVariant(platformId);
selectedPlatformIds.value.push(platformId);
};
// Save logic
@ -415,9 +236,7 @@ const getSubmitData = () => {
content: content.value,
media: media.value,
platforms,
scheduled_at: scheduledDateTime.value
? dayjs(scheduledDateTime.value).utc().format()
: null,
scheduled_at: date.formatLocalDateTimeForApi(scheduledDateTime.value),
label_ids: selectedLabelIds.value,
};
};
@ -462,7 +281,7 @@ onUnmounted(() => {
debouncedSave.cancel();
});
const submit = (status: string = 'scheduled') => {
const submit = (status: string = PostStatus.Scheduled) => {
if (isSubmitting.value || isReadOnly.value) return;
debouncedSave.cancel();
@ -499,13 +318,9 @@ const unschedulePost = () => {
debouncedSave.cancel();
scheduledDateTime.value = '';
hasPickedTime.value = false;
submit('draft');
submit(PostStatus.Draft);
};
// Echo: listen for real-time platform status updates.
// Event fires when any post_platform completes publishing (success or fail).
// Full reload of the post prop so the new status + post_platforms propagate and
// the overlay dismisses.
usePostEcho(post.value.id, '.post.platform.status.updated', () => {
router.reload({ only: ['post'] });
});

View file

@ -37,6 +37,7 @@ import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { copyToClipboard } from '@/lib/utils';
import { PostStatus } from '@/types/post';
interface SocialAccount {
id: string;
platform: string;
@ -136,7 +137,10 @@ const getEnabledPlatforms = (post: Post) => post.post_platforms.filter((pp) => p
const getPostPreview = (post: Post): string =>
post.content?.trim() || trans('calendar.no_content');
const canEdit = (post: Post): boolean => ['draft', 'scheduled', 'failed'].includes(post.status);
const EDITABLE_STATUSES: readonly string[] = [PostStatus.Draft, PostStatus.Scheduled];
const DELETABLE_STATUSES: readonly string[] = [PostStatus.Draft, PostStatus.Scheduled, PostStatus.Failed];
const canEdit = (post: Post): boolean => EDITABLE_STATUSES.includes(post.status);
const canDelete = (post: Post): boolean => DELETABLE_STATUSES.includes(post.status);
const postUrl = (post: Post): string =>
canEdit(post) ? editPost.url(post.id) : showPost.url(post.id);
@ -301,7 +305,7 @@ useWorkspaceEcho(
<IconCopy class="size-4" />
{{ $t('posts.actions.copy_id') }}
</DropdownMenuItem>
<template v-if="canEdit(post)">
<template v-if="canDelete(post)">
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"

View file

@ -18,6 +18,7 @@ import { getPlatformStatusConfig, getPostStatusConfig } from '@/composables/useP
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as postsIndex } from '@/routes/app/posts';
import { PostPlatformStatus, PostStatus } from '@/types/post';
interface MediaItem {
id: string;
@ -73,7 +74,7 @@ const props = defineProps<{
const enabledPlatforms = computed(() => props.post.platforms.filter((pp) => pp.enabled));
const isPublishing = computed(() => props.post.status === 'publishing');
const isPublishing = computed(() => props.post.status === PostStatus.Publishing);
const postStatus = computed(() => getPostStatusConfig(props.post.status));
@ -252,11 +253,11 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
<component
:is="getPlatformStatusConfig(pp.status).icon"
class="size-3"
:class="pp.status === 'publishing' ? 'animate-spin' : ''"
:class="pp.status === PostPlatformStatus.Publishing ? 'animate-spin' : ''"
/>
{{ getPlatformStatusConfig(pp.status).label }}
</Badge>
<TooltipProvider v-if="pp.status === 'published' && pp.platform_url" :delay-duration="200">
<TooltipProvider v-if="pp.status === PostPlatformStatus.Published && pp.platform_url" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<a
@ -276,7 +277,7 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
<!-- Failed: error message -->
<div
v-if="pp.status === 'failed' && pp.error_message"
v-if="pp.status === PostPlatformStatus.Failed && pp.error_message"
class="border-t-2 border-foreground/10 bg-rose-50 px-4 py-3 text-xs font-semibold text-rose-700"
>
{{ pp.error_message }}
@ -284,7 +285,7 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
<!-- Published: metrics -->
<PostPlatformMetrics
v-if="pp.status === 'published'"
v-if="pp.status === PostPlatformStatus.Published"
:post-id="post.id"
:post-platform-id="pp.id"
/>

View file

@ -0,0 +1,20 @@
export const PostStatus = {
Draft: 'draft',
Scheduled: 'scheduled',
Publishing: 'publishing',
Published: 'published',
PartiallyPublished: 'partially_published',
Failed: 'failed',
} as const;
export type PostStatusValue = (typeof PostStatus)[keyof typeof PostStatus];
export const PostPlatformStatus = {
Pending: 'pending',
Publishing: 'publishing',
Published: 'published',
Failed: 'failed',
Retrying: 'retrying',
} as const;
export type PostPlatformStatusValue = (typeof PostPlatformStatus)[keyof typeof PostPlatformStatus];

View file

@ -196,11 +196,11 @@
->assertNotFound();
});
it('cannot update published post', function () {
it('cannot update post in any terminal state', function (PostStatus $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Published,
'status' => $status,
]);
$postPlatform = PostPlatform::factory()->linkedin()->create([
@ -220,7 +220,12 @@
],
])
->assertUnprocessable();
});
})->with([
PostStatus::Published,
PostStatus::PartiallyPublished,
PostStatus::Failed,
PostStatus::Publishing,
]);
it('validates post update fields', function () {
$post = Post::factory()->create([

View file

@ -106,18 +106,23 @@
$response->assertHasErrors(['Post not found.']);
});
test('update post rejects already-published post', function () {
test('update post rejects posts in any terminal state', function (PostStatus $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Published,
'status' => $status,
]);
$response = TryPostServer::actingAs($this->user)
->tool(UpdatePostTool::class, ['post_id' => $post->id, 'content' => 'x']);
$response->assertHasErrors(['Cannot edit a published post.']);
});
$response->assertHasErrors(['Cannot edit a post in a terminal state.']);
})->with([
PostStatus::Published,
PostStatus::PartiallyPublished,
PostStatus::Failed,
PostStatus::Publishing,
]);
test('update post rejects a platforms[].id that belongs to another post', function () {
$myPost = Post::factory()->create([
@ -256,3 +261,26 @@
$response->assertHasErrors(['Post not found.']);
});
test('publish post rejects posts already in a terminal state', function (PostStatus $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => $status,
]);
PostPlatform::factory()->linkedin()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
]);
$response = TryPostServer::actingAs($this->user)
->tool(PublishPostTool::class, ['post_id' => $post->id]);
$response->assertHasErrors(['Post is already published or in a terminal state.']);
})->with([
PostStatus::Published,
PostStatus::PartiallyPublished,
PostStatus::Failed,
PostStatus::Publishing,
]);

View file

@ -7,12 +7,14 @@
use App\Enums\PostPlatform\Status;
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Jobs\PublishPost;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceLabel;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
@ -308,7 +310,7 @@
});
test('edit redirects to show for non-editable statuses', function () {
foreach ([PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Publishing] as $status) {
foreach ([PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Publishing, PostStatus::Failed] as $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
@ -326,8 +328,8 @@
}
});
test('edit allows draft, scheduled, and failed posts', function () {
foreach ([PostStatus::Draft, PostStatus::Scheduled, PostStatus::Failed] as $status) {
test('edit allows draft and scheduled posts', function () {
foreach ([PostStatus::Draft, PostStatus::Scheduled] as $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
@ -415,6 +417,138 @@
$response->assertRedirect();
});
test('cannot re-publish a failed post', function () {
Bus::fake();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Failed,
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
]);
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'publishing',
'content' => 'Test content',
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::LinkedInPost->value,
],
],
]);
$response->assertRedirect();
$response->assertSessionHas('flash.bannerStyle', 'danger');
$post->refresh();
expect($post->status)->toBe(PostStatus::Failed);
Bus::assertNotDispatched(PublishPost::class);
});
test('cannot update a post in publishing state', function () {
Bus::fake();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
]);
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'content' => 'Test content',
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::LinkedInPost->value,
],
],
]);
$response->assertRedirect();
$response->assertSessionHas('flash.bannerStyle', 'danger');
$post->refresh();
expect($post->status)->toBe(PostStatus::Publishing);
Bus::assertNotDispatched(PublishPost::class);
});
test('cannot update a partially published post', function () {
Bus::fake();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::PartiallyPublished,
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
]);
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'publishing',
'content' => 'Test content',
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::LinkedInPost->value,
],
],
]);
$response->assertRedirect();
$response->assertSessionHas('flash.bannerStyle', 'danger');
$post->refresh();
expect($post->status)->toBe(PostStatus::PartiallyPublished);
Bus::assertNotDispatched(PublishPost::class);
});
test('cannot update a published post', function () {
Bus::fake();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Published,
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
]);
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
'status' => 'publishing',
'content' => 'Test content',
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::LinkedInPost->value,
],
],
]);
$response->assertRedirect();
$response->assertSessionHas('flash.bannerStyle', 'danger');
$post->refresh();
expect($post->status)->toBe(PostStatus::Published);
Bus::assertNotDispatched(PublishPost::class);
});
test('publish now updates scheduled_at to current time', function () {
Mail::fake();
$this->freezeTime();
@ -769,7 +903,7 @@
});
test('show page redirects editable posts to edit', function () {
foreach ([PostStatus::Draft, PostStatus::Scheduled, PostStatus::Failed] as $status) {
foreach ([PostStatus::Draft, PostStatus::Scheduled] as $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
@ -782,6 +916,18 @@
}
});
test('failed posts render show without redirecting to edit', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Failed,
]);
$this->actingAs($this->user)
->get(route('app.posts.show', $post))
->assertOk();
});
test('destroy blocks published posts', function () {
foreach ([PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished] as $status) {
$post = Post::factory()->create([

View file

@ -499,3 +499,125 @@
expect($result['id'])->toBe('post-123');
});
test('single image post without caption omits message from payload', function () {
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
'*/page_123/photos' => Http::response(['id' => 'photo-123', 'post_id' => 'post-123'], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/photos')
&& ! array_key_exists('message', $request->data());
});
});
test('multi-image post without caption omits message from payload', function () {
$mediaItems = [];
for ($i = 1; $i <= 2; $i++) {
$mediaItems[] = [
'id' => "test-media-{$i}",
'path' => "media/2026-01/image{$i}.jpg",
'url' => "https://example.com/media/2026-01/image{$i}.jpg",
'mime_type' => 'image/jpeg',
'original_filename' => "image{$i}.jpg",
];
}
$this->post->update(['content' => null, 'media' => $mediaItems]);
Http::fake([
'*/page_123/photos' => Http::sequence()
->push(['id' => 'photo_1'], 200)
->push(['id' => 'photo_2'], 200),
'*/page_123/feed' => Http::response(['id' => 'multi_post_789'], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/feed')
&& ! array_key_exists('message', $request->data());
});
});
test('video post without description omits description from payload', function () {
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/video.mp4',
'url' => 'https://example.com/media/2026-01/video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'video.mp4',
],
],
]);
Http::fake([
'*/page_123/videos' => Http::response(['id' => 'video_123'], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/videos')
&& ! array_key_exists('description', $request->data());
});
});
test('reel post without description omits description from payload (finish phase)', function () {
$this->postPlatform->update(['content_type' => ContentType::FacebookReel]);
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-reel',
'path' => 'media/2026-01/reel.mp4',
'url' => 'https://example.com/media/2026-01/reel.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'reel.mp4',
],
],
]);
Http::fake([
'*/page_123/video_reels' => Http::sequence()
->push([
'video_id' => 'reel_video_123',
'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_123',
], 200)
->push(['id' => 'reel_123', 'success' => true], 200),
'*example.com/media/*' => Http::response('fake-video-binary-content', 200),
'*rupload.facebook.com/*' => Http::response(['success' => true], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/page_123/video_reels')) {
return false;
}
$data = $request->data();
return data_get($data, 'upload_phase') === 'finish'
&& ! array_key_exists('description', $data);
});
});

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Enums\PostPlatform\Status;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->socialAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
]);
$this->postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
]);
});
test('markAsPublished clears stale error_message and error_context', function () {
$this->postPlatform->update([
'status' => Status::Failed,
'error_message' => 'The post is empty. Please enter a message to share.',
'error_context' => ['platform_error_code' => 197, 'content_length' => 0],
]);
$this->postPlatform->markAsPublished('platform_post_abc', 'https://www.facebook.com/platform_post_abc');
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(Status::Published)
->and($this->postPlatform->platform_post_id)->toBe('platform_post_abc')
->and($this->postPlatform->error_message)->toBeNull()
->and($this->postPlatform->error_context)->toBeNull();
});
test('markAsFailed clears stale platform_post_id and platform_url', function () {
$this->postPlatform->update([
'status' => Status::Published,
'platform_post_id' => 'old_post_id',
'platform_url' => 'https://www.facebook.com/old_post_id',
'published_at' => now(),
]);
$this->postPlatform->markAsFailed('Something went wrong.', ['platform_error_code' => 500]);
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(Status::Failed)
->and($this->postPlatform->error_message)->toBe('Something went wrong.')
->and($this->postPlatform->platform_post_id)->toBeNull()
->and($this->postPlatform->platform_url)->toBeNull();
});