diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index a9f5ed4a..c37a54ad 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -6,9 +6,11 @@ use App\Enums\Post\Status; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; use App\Rules\ContentTypeCompatibleWithMedia; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class UpdatePostRequest extends FormRequest { @@ -79,4 +81,44 @@ public function messages(): array 'scheduled_at.after' => 'The scheduled date must be in the future.', ]; } + + public function withValidator(Validator $validator): void + { + $validator->after(function ($validator): void { + if (! $this->isPublishingOrScheduling()) { + return; + } + + $platforms = $this->input('platforms', []); + $ids = collect($platforms)->pluck('id')->filter()->all(); + + // Scope to the current post via the route-bound relation — defensive + // (cross-post leakage is prevented and we use the existing relation + // rather than a separate global query). + $platformsById = $this->route('post') + ->postPlatforms() + ->whereIn('id', $ids) + ->pluck('platform', 'id'); + + foreach ($platforms as $i => $platform) { + $platformEnum = $platformsById[data_get($platform, 'id')] ?? null; + if ($platformEnum === Platform::TikTok + && blank(data_get($platform, 'meta.privacy_level'))) { + $validator->errors()->add( + "platforms.{$i}.meta.privacy_level", + 'TikTok privacy level is required when publishing.', + ); + } + } + }); + } + + private function isPublishingOrScheduling(): bool + { + return in_array( + $this->input('status'), + [Status::Scheduled->value, Status::Publishing->value], + true, + ); + } } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 52209a8b..5b2faf74 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -75,26 +75,24 @@ private function getHttpClient(): PendingRequest return $this->socialHttp()->asJson()->withToken($this->accessToken); } - private function queryCreatorInfo(SocialAccount $account): array + /** + * Resolve the user-selected privacy_level from meta, throwing when missing. + * TikTok UX Guideline Point 2b forbids any default — the user must pick + * explicitly. The FormRequest validates this upstream; this is the safety + * net for queue/job paths that bypass the request layer. + */ + private function resolveRequiredPrivacyLevel(PostPlatform $postPlatform): string { - $info = app(TikTokCreatorInfo::class)->fetch($account); - $privacyOptions = data_get($info, 'privacy_level_options') ?: ['SELF_ONLY']; + $privacyLevel = data_get($postPlatform->meta ?? [], 'privacy_level'); - // Prefer PUBLIC_TO_EVERYONE > MUTUAL_FOLLOW_FRIENDS > FOLLOWER_OF_CREATOR > SELF_ONLY - $preferred = ['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY']; - - $privacyLevel = 'SELF_ONLY'; - foreach ($preferred as $level) { - if (in_array($level, $privacyOptions)) { - $privacyLevel = $level; - break; - } + if (blank($privacyLevel)) { + throw new TikTokPublishException( + userMessage: 'TikTok privacy level is required. Please open the post and pick a visibility option.', + category: ErrorCategory::ContentPolicy, + ); } - return [ - 'privacy_level' => $privacyLevel, - 'max_video_post_duration_sec' => data_get($info, 'max_video_post_duration_sec'), - ]; + return (string) $privacyLevel; } /** @@ -104,16 +102,13 @@ private function queryCreatorInfo(SocialAccount $account): array * * @return array */ - private function buildVideoPostInfo(PostPlatform $postPlatform, ?string $content, array $creatorInfo): array + private function buildVideoPostInfo(PostPlatform $postPlatform, ?string $content): array { $meta = $postPlatform->meta ?? []; - $privacyLevel = data_get($meta, 'privacy_level') - ?: data_get($creatorInfo, 'privacy_level', 'SELF_ONLY'); - $postInfo = [ 'title' => $content ?? '', - 'privacy_level' => $privacyLevel, + 'privacy_level' => $this->resolveRequiredPrivacyLevel($postPlatform), 'disable_duet' => ! data_get($meta, 'allow_duet', false), 'disable_comment' => ! data_get($meta, 'allow_comments', true), 'disable_stitch' => ! data_get($meta, 'allow_stitch', false), @@ -142,16 +137,13 @@ private function buildVideoPostInfo(PostPlatform $postPlatform, ?string $content * * @return array */ - private function buildPhotoPostInfo(PostPlatform $postPlatform, ?string $content, array $creatorInfo): array + private function buildPhotoPostInfo(PostPlatform $postPlatform, ?string $content): array { $meta = $postPlatform->meta ?? []; - $privacyLevel = data_get($meta, 'privacy_level') - ?: data_get($creatorInfo, 'privacy_level', 'SELF_ONLY'); - $postInfo = [ 'description' => $content ?? '', - 'privacy_level' => $privacyLevel, + 'privacy_level' => $this->resolveRequiredPrivacyLevel($postPlatform), 'disable_comment' => ! data_get($meta, 'allow_comments', true), ]; @@ -168,11 +160,9 @@ private function buildPhotoPostInfo(PostPlatform $postPlatform, ?string $content private function publishVideo(PostPlatform $postPlatform, $media, ?string $content): array { - $creatorInfo = $this->queryCreatorInfo($postPlatform->socialAccount); - $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/video/init/", [ - 'post_info' => $this->buildVideoPostInfo($postPlatform, $content, $creatorInfo), + 'post_info' => $this->buildVideoPostInfo($postPlatform, $content), 'source_info' => [ 'source' => 'PULL_FROM_URL', 'video_url' => $media->url, @@ -223,9 +213,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st ); } - $creatorInfo = $this->queryCreatorInfo($postPlatform->socialAccount); - - $postInfo = $this->buildPhotoPostInfo($postPlatform, $content, $creatorInfo); + $postInfo = $this->buildPhotoPostInfo($postPlatform, $content); // Auto add music is only for photos. $meta = $postPlatform->meta ?? []; diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index 14c1884a..508abec8 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -111,6 +111,7 @@ public function tiktok(): static return $this->state(fn (array $attributes) => [ 'platform' => Platform::TikTok, 'content_type' => ContentType::TikTokVideo, + 'meta' => ['privacy_level' => 'SELF_ONLY'], ]); } diff --git a/lang/en/posts.php b/lang/en/posts.php index 3b6904c0..a7a684aa 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -49,6 +49,11 @@ 'write_caption' => 'Write your caption...', 'tiktok' => [ 'settings' => 'TikTok Settings', + 'variant_label' => 'Post type', + 'variant' => [ + 'video' => 'Video', + 'photo' => 'Photo carousel', + ], 'posting_to' => 'Posting to', 'privacy_level' => 'Who can see this video?', 'privacy_placeholder' => 'Select visibility', @@ -57,6 +62,7 @@ 'friends' => 'Mutual follow friends', 'followers' => 'Followers', 'private' => 'Only me', + 'private_disabled_branded' => 'Branded content visibility cannot be set to private.', ], 'privacy_hint' => 'The available options depend on your TikTok account settings.', 'auto_add_music' => 'Auto add music', @@ -74,7 +80,7 @@ 'promotional_paid_title' => 'Your photo/video will be labeled as "Paid partnership".', 'promotional_description' => 'This cannot be changed once your video is posted.', 'compliance_incomplete' => 'You need to indicate if your content promotes yourself, a third party, or both.', - 'branded_blocks_private' => 'Branded content cannot be private. Choose Public or Mutual follow friends.', + 'branded_cleared_private' => 'Privacy was cleared because Branded Content cannot be private.', 'interaction_disabled_by_creator' => 'Disabled by your TikTok account settings.', 'max_duration_exceeded' => 'Video is :duration s long but this account can only post videos up to :max s.', 'creator_info_loading' => 'Loading your TikTok account settings…', diff --git a/lang/es/posts.php b/lang/es/posts.php index 7adbca73..68777104 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -49,6 +49,11 @@ 'write_caption' => 'Escribe tu descripción...', 'tiktok' => [ 'settings' => 'Configuración de TikTok', + 'variant_label' => 'Tipo de publicación', + 'variant' => [ + 'video' => 'Video', + 'photo' => 'Carrusel de fotos', + ], 'posting_to' => 'Publicando en', 'privacy_level' => '¿Quién puede ver este video?', 'privacy_placeholder' => 'Selecciona la visibilidad', @@ -57,6 +62,7 @@ 'friends' => 'Amigos mutuos', 'followers' => 'Seguidores', 'private' => 'Solo yo', + 'private_disabled_branded' => 'El contenido de marca no puede ser privado.', ], 'privacy_hint' => 'Las opciones disponibles dependen de la configuración de tu cuenta de TikTok.', 'auto_add_music' => 'Agregar música automáticamente', @@ -74,7 +80,7 @@ 'promotional_paid_title' => 'Tu foto/video será etiquetado como "Asociación pagada".', 'promotional_description' => 'Esto no se puede cambiar una vez publicado el video.', 'compliance_incomplete' => 'Debes indicar si tu contenido promociona a ti mismo, a un tercero o a ambos.', - 'branded_blocks_private' => 'El contenido patrocinado no puede ser privado. Elige Público o Amigos mutuos.', + 'branded_cleared_private' => 'La visibilidad se borró porque el contenido de marca no puede ser privado.', 'interaction_disabled_by_creator' => 'Desactivado por la configuración de tu cuenta TikTok.', 'max_duration_exceeded' => 'El video dura :duration s pero esta cuenta solo permite videos de hasta :max s.', 'creator_info_loading' => 'Cargando la configuración de tu cuenta TikTok…', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index f7754c83..d7a4da21 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -49,6 +49,11 @@ 'write_caption' => 'Escreva sua legenda...', 'tiktok' => [ 'settings' => 'Configurações do TikTok', + 'variant_label' => 'Tipo de publicação', + 'variant' => [ + 'video' => 'Vídeo', + 'photo' => 'Carrossel de fotos', + ], 'posting_to' => 'Publicando em', 'privacy_level' => 'Quem pode ver este vídeo?', 'privacy_placeholder' => 'Selecione a visibilidade', @@ -57,6 +62,7 @@ 'friends' => 'Amigos em comum', 'followers' => 'Seguidores', 'private' => 'Apenas eu', + 'private_disabled_branded' => 'Conteúdo de marca não pode ser privado.', ], 'privacy_hint' => 'As opções disponíveis dependem das configurações da sua conta TikTok.', 'auto_add_music' => 'Adicionar música automaticamente', @@ -74,7 +80,7 @@ 'promotional_paid_title' => 'Sua foto/vídeo será rotulado como "Parceria paga".', 'promotional_description' => 'Isso não poderá ser alterado após a publicação.', 'compliance_incomplete' => 'Você precisa indicar se o conteúdo promove você mesmo, terceiros ou ambos.', - 'branded_blocks_private' => 'Conteúdo patrocinado não pode ser privado. Escolha Público ou Amigos em comum.', + 'branded_cleared_private' => 'A visibilidade foi limpa porque conteúdo de marca não pode ser privado.', 'interaction_disabled_by_creator' => 'Desativado pelas configurações da sua conta TikTok.', 'max_duration_exceeded' => 'Vídeo tem :duration s mas esta conta só permite vídeos de até :max s.', 'creator_info_loading' => 'Carregando configurações da sua conta TikTok…', diff --git a/resources/js/components/posts/editor/ScheduleTab.vue b/resources/js/components/posts/editor/ScheduleTab.vue index 5e0cea66..8fc366d6 100644 --- a/resources/js/components/posts/editor/ScheduleTab.vue +++ b/resources/js/components/posts/editor/ScheduleTab.vue @@ -12,6 +12,7 @@ import { Avatar } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo'; +import { Platform } from '@/enums/platform'; interface SocialAccount { id: string; @@ -97,33 +98,33 @@ const emit = defineEmits<{ const selectedTikTokPlatforms = computed(() => props.postPlatforms.filter( - (pp) => pp.platform === 'tiktok' && props.selectedPlatformIds.includes(pp.id), + (pp) => pp.platform === Platform.TikTok && props.selectedPlatformIds.includes(pp.id), ), ); const selectedInstagramPlatforms = computed(() => props.postPlatforms.filter( - (pp) => ['instagram', 'instagram-facebook'].includes(pp.platform) + (pp) => (pp.platform === Platform.Instagram || pp.platform === Platform.InstagramFacebook) && props.selectedPlatformIds.includes(pp.id), ), ); const selectedFacebookPlatforms = computed(() => props.postPlatforms.filter( - (pp) => pp.platform === 'facebook' && props.selectedPlatformIds.includes(pp.id), + (pp) => pp.platform === Platform.Facebook && props.selectedPlatformIds.includes(pp.id), ), ); const selectedLinkedInPlatforms = computed(() => props.postPlatforms.filter( - (pp) => ['linkedin', 'linkedin-page'].includes(pp.platform) + (pp) => (pp.platform === Platform.LinkedIn || pp.platform === Platform.LinkedInPage) && props.selectedPlatformIds.includes(pp.id), ), ); const selectedPinterestPlatforms = computed(() => props.postPlatforms.filter( - (pp) => pp.platform === 'pinterest' && props.selectedPlatformIds.includes(pp.id), + (pp) => pp.platform === Platform.Pinterest && props.selectedPlatformIds.includes(pp.id), ), ); @@ -267,8 +268,10 @@ const getPlatformAvatar = (pp: PostPlatform): string | null => :creator-info="getCreatorInfo(pp)" :creator-info-loading="tiktokCreatorInfos === undefined || tiktokCreatorInfos === null" :video-duration-sec="videoDurationSec" + :content-type="platformContentTypes[pp.id] ?? ''" :meta="platformMeta[pp.id] ?? {}" :disabled="isReadOnly" + @update:content-type="emit('update:platformContentType', pp.id, $event)" @update:meta="emit('update:platformMeta', pp.id, $event)" /> diff --git a/resources/js/components/posts/editor/TikTokSettings.vue b/resources/js/components/posts/editor/TikTokSettings.vue index bf735c63..c4511ee6 100644 --- a/resources/js/components/posts/editor/TikTokSettings.vue +++ b/resources/js/components/posts/editor/TikTokSettings.vue @@ -1,6 +1,8 @@