fix(pinterest): restore board picker + require board_id in validation

The post editor lost the Pinterest board picker during a UI rewrite,
causing scheduled posts to fail in production with 'Pinterest board_id
is required'. This restores the picker and locks the contract with
validation + tests so the regression cannot happen silently again.

Backend:
- PostController: pinterestBoards is now Record<account_id, Board[]>
  (mirrors the TikTok creator-info pattern); supports multi-account.
- UpdatePostRequest: 'platforms.*.meta.board_id' rule + after-validator
  rejects Publishing/Scheduling Pinterest posts without board_id.

Frontend:
- PinterestSettings.vue: Combobox board picker with empty-state warning;
  emits update:meta with board_id.
- ScheduleTab / PostEditorSidebar / Edit pass pinterestBoards down by
  social_account_id.

Tests (6 new):
- UpdatePostRequestTest: rejects publishing/scheduling without board_id
  across pin/carousel/video pin; allows draft without board_id;
  pinterest error doesn't block sibling platforms in multi-platform.
- PinterestPublisherTest: publisher throws for carousel + video pin
  when no board_id (existing image-pin case kept).

1542 tests passing.
This commit is contained in:
Paulo Castellano 2026-05-15 11:51:20 -03:00
parent a9e8efffea
commit 44d891ef08
14 changed files with 427 additions and 13 deletions

View file

@ -234,15 +234,15 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
$account->id => new PlatformConfigResource($account),
]);
$pinterestBoards = [];
$pinterestAccount = $socialAccounts->firstWhere('platform', Platform::Pinterest);
if ($pinterestAccount) {
try {
$pinterestBoards = app(PinterestPublisher::class)->getBoards($pinterestAccount);
} catch (\Exception $e) {
// Silently fail - boards will be empty
}
}
$pinterestBoards = $socialAccounts
->where('platform', Platform::Pinterest)
->mapWithKeys(fn ($account) => [
$account->id => rescue(
fn () => app(PinterestPublisher::class)->getBoards($account),
[],
report: false,
),
]);
$tiktokCreatorInfos = $socialAccounts
->where('platform', Platform::TikTok)

View file

@ -77,6 +77,7 @@ public function rules(): array
'platforms.*.meta.disclose' => ['sometimes', 'boolean'],
'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'],
'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'],
'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'],
'label_ids' => ['sometimes', 'array'],
'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)],
];
@ -106,6 +107,14 @@ public function withValidator(Validator $validator): void
trans('posts.form.tiktok.privacy_required'),
);
}
if ($platformEnum === Platform::Pinterest
&& blank(data_get($platform, 'meta.board_id'))) {
$validator->errors()->add(
"platforms.{$i}.meta.board_id",
trans('posts.form.pinterest.board_required'),
);
}
}
});
}

View file

@ -147,6 +147,12 @@
'video_pin' => 'Video Pin',
'carousel' => 'Carousel',
],
'board' => 'Board',
'select_board' => 'Select a board',
'no_boards' => 'No Pinterest boards found. Create one in your Pinterest account first.',
'search_board' => 'Search boards...',
'no_board_found' => 'No board matches your search.',
'board_required' => 'Select a Pinterest board to publish this post.',
],
'warnings' => [
'no_variant' => 'Pick a post type to continue.',

View file

@ -147,6 +147,12 @@
'video_pin' => 'Video Pin',
'carousel' => 'Carrusel',
],
'board' => 'Tablero',
'select_board' => 'Selecciona un tablero',
'no_boards' => 'No se encontraron tableros de Pinterest. Crea uno en tu cuenta de Pinterest primero.',
'search_board' => 'Buscar tableros...',
'no_board_found' => 'Ningún tablero coincide con tu búsqueda.',
'board_required' => 'Selecciona un tablero de Pinterest para publicar este post.',
],
'warnings' => [
'no_variant' => 'Elige un tipo de publicación para continuar.',

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

@ -147,6 +147,12 @@
'video_pin' => 'Video Pin',
'carousel' => 'Carrossel',
],
'board' => 'Quadro',
'select_board' => 'Selecione um quadro',
'no_boards' => 'Nenhum quadro do Pinterest encontrado. Crie um na sua conta do Pinterest primeiro.',
'search_board' => 'Pesquisar quadros...',
'no_board_found' => 'Nenhum quadro encontrado.',
'board_required' => 'Selecione um quadro do Pinterest para publicar este post.',
],
'warnings' => [
'no_variant' => 'Escolha um tipo de publicação para continuar.',

View file

@ -3,6 +3,16 @@ import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import {
Combobox,
ComboboxAnchor,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from '@/components/ui/combobox';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
@ -15,10 +25,17 @@ interface SocialAccount {
avatar_url: string | null;
}
interface BoardOption {
value: string;
label: string;
}
interface Props {
socialAccount: SocialAccount | null;
contentType: string;
media: MediaItem[];
boards: Array<{ id: string; name: string }>;
meta: Record<string, any>;
disabled?: boolean;
}
@ -28,6 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
'update:contentType': [value: string];
'update:meta': [value: Record<string, any>];
}>();
const open = ref(false);
@ -44,6 +62,15 @@ const pickVariant = (value: string) => {
};
const warning = computed(() => getMediaValidationWarning(props.contentType, props.media));
const boardOptions = computed<BoardOption[]>(() =>
props.boards.map((b) => ({ value: b.id, label: b.name })),
);
const selectedBoard = computed<BoardOption | undefined>({
get: () => boardOptions.value.find((b) => b.value === props.meta?.board_id),
set: (board) => emit('update:meta', { ...props.meta, board_id: board?.value ?? null }),
});
</script>
<template>
@ -99,6 +126,51 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</div>
</div>
<div class="space-y-2">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.pinterest.board') }}</p>
<p
v-if="boards.length === 0"
class="flex items-start gap-2 rounded-lg border-2 border-foreground/30 bg-foreground/5 p-2 text-xs font-semibold text-foreground/60"
>
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
{{ $t('posts.form.pinterest.no_boards') }}
</p>
<Combobox
v-else
v-model="selectedBoard"
:display-value="(b: any) => b?.label ?? ''"
:disabled="disabled"
>
<ComboboxAnchor class="w-full">
<ComboboxTrigger as-child>
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border-2 border-foreground/30 bg-card px-3 py-2 text-sm font-medium text-foreground transition-colors hover:border-foreground disabled:cursor-not-allowed disabled:opacity-50"
:disabled="disabled"
>
<span :class="selectedBoard ? 'text-foreground' : 'text-foreground/50'">
{{ selectedBoard ? selectedBoard.label : $t('posts.form.pinterest.select_board') }}
</span>
<IconChevronDown class="size-4 shrink-0 text-foreground/60" />
</button>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxList>
<ComboboxInput :placeholder="$t('posts.form.pinterest.search_board')" />
<ComboboxEmpty>{{ $t('posts.form.pinterest.no_board_found') }}</ComboboxEmpty>
<ComboboxGroup>
<ComboboxItem
v-for="board in boardOptions"
:key="board.value"
:value="board"
>
{{ board.label }}
</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</Combobox>
</div>
<p
v-if="warning"
class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700"

View file

@ -71,6 +71,7 @@ const props = defineProps<{
labels: { id: string; name: string; color: string }[];
selectedLabelIds: string[];
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
pinterestBoards?: Record<string, Array<{ id: string; name: string }>> | null;
isReadOnly: boolean;
authUserId: string;
initialHighlightCommentId: string | null;
@ -127,6 +128,7 @@ defineExpose({
:platform-content-types="platformContentTypes"
:platform-issues="platformIssues"
:tiktok-creator-infos="tiktokCreatorInfos"
:pinterest-boards="pinterestBoards"
:media="media"
@toggle-platform="(id) => emit('toggle-platform', id)"
@toggle-label="(id) => emit('toggle-label', id)"

View file

@ -86,6 +86,7 @@ const props = defineProps<{
platformContentTypes: Record<string, string>;
platformIssues?: Record<string, string>;
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
pinterestBoards?: Record<string, Array<{ id: string; name: string }>> | null;
media?: MediaItem[];
}>();
@ -134,6 +135,9 @@ const getPublishConfig = (pp: PostPlatform): Record<string, any> | null =>
const getCreatorInfo = (pp: PostPlatform): TikTokCreatorInfo | null =>
pp.social_account_id ? props.tiktokCreatorInfos?.[pp.social_account_id] ?? null : null;
const getBoards = (pp: PostPlatform): Array<{ id: string; name: string }> =>
pp.social_account_id ? props.pinterestBoards?.[pp.social_account_id] ?? [] : [];
const videoDurationSec = computed(() => {
const video = props.media?.find((m) => m.type === 'video' || m.mime_type?.startsWith('video/'));
const duration = video?.meta?.duration;
@ -321,8 +325,11 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
:social-account="pp.social_account"
:content-type="platformContentTypes[pp.id] ?? ''"
:media="media ?? []"
:boards="getBoards(pp)"
:meta="platformMeta[pp.id] ?? {}"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"
@update:meta="emit('update:platformMeta', pp.id, $event)"
/>
</div>

View file

@ -88,7 +88,7 @@ const props = defineProps<{
post: Post;
socialAccounts: SocialAccount[];
platformConfigs: Record<string, any>;
pinterestBoards: any[];
pinterestBoards: Record<string, Array<{ id: string; name: string }>>;
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
labels: { id: string; name: string; color: string }[];
signatures: { id: string; name: string; content: string }[];
@ -576,6 +576,7 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
:labels="labels"
:selected-label-ids="selectedLabelIds"
:tiktok-creator-infos="tiktokCreatorInfos"
:pinterest-boards="pinterestBoards"
:is-read-only="isLocked"
:auth-user-id="authUserId"
:initial-highlight-comment-id="initialHighlightCommentId"

View file

@ -113,6 +113,41 @@
->toThrow(Exception::class, 'Pinterest board_id is required');
});
test('pinterest publisher throws exception for carousel when no board id', function () {
$this->postPlatform->update([
'content_type' => ContentType::PinterestCarousel,
'meta' => [],
]);
$this->socialAccount->update(['meta' => []]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/img1.jpg', 'url' => 'https://example.com/img1.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'img1.jpg'],
['id' => 'm2', 'path' => 'media/img2.jpg', 'url' => 'https://example.com/img2.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'img2.jpg'],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'Pinterest board_id is required');
});
test('pinterest publisher throws exception for video pin when no board id', function () {
$this->postPlatform->update([
'content_type' => ContentType::PinterestVideoPin,
'meta' => [],
]);
$this->socialAccount->update(['meta' => []]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/video.mp4', 'url' => 'https://example.com/video.mp4', 'mime_type' => 'video/mp4', 'original_filename' => 'video.mp4'],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'Pinterest board_id is required');
});
test('pinterest publisher uses default board id from account', function () {
$this->postPlatform->update(['meta' => []]); // No board_id in post meta

View file

@ -80,6 +80,276 @@
$response->assertSessionDoesntHaveErrors(['platforms.0.meta.privacy_level']);
});
test('publishing a pinterest post without board_id is rejected', function () {
$pinterestAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Pinterest,
]);
$pinterestPlatform = PostPlatform::factory()->pinterest()->create([
'post_id' => $this->post->id,
'social_account_id' => $pinterestAccount->id,
'meta' => [],
]);
$mediaPayload = [
[
'id' => 'test-image',
'path' => 'media/2026-01/pin.jpg',
'url' => 'https://example.com/media/2026-01/pin.jpg',
'type' => 'image',
'mime_type' => 'image/jpeg',
'original_filename' => 'pin.jpg',
],
];
$response = $this->actingAs($this->user)
->put(route('app.posts.update', $this->post), [
'status' => Status::Publishing->value,
'media' => $mediaPayload,
'platforms' => [
[
'id' => $pinterestPlatform->id,
'content_type' => ContentType::PinterestPin->value,
'meta' => [],
],
],
]);
$response->assertSessionHasErrors('platforms.0.meta.board_id');
});
test('publishing a pinterest post with board_id passes board validation', function () {
$pinterestAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Pinterest,
]);
$pinterestPlatform = PostPlatform::factory()->pinterest()->create([
'post_id' => $this->post->id,
'social_account_id' => $pinterestAccount->id,
'meta' => [],
]);
$mediaPayload = [
[
'id' => 'test-image',
'path' => 'media/2026-01/pin.jpg',
'url' => 'https://example.com/media/2026-01/pin.jpg',
'type' => 'image',
'mime_type' => 'image/jpeg',
'original_filename' => 'pin.jpg',
],
];
$response = $this->actingAs($this->user)
->put(route('app.posts.update', $this->post), [
'status' => Status::Publishing->value,
'media' => $mediaPayload,
'platforms' => [
[
'id' => $pinterestPlatform->id,
'content_type' => ContentType::PinterestPin->value,
'meta' => ['board_id' => '123456789'],
],
],
]);
$response->assertSessionDoesntHaveErrors(['platforms.0.meta.board_id']);
});
test('scheduling a pinterest post without board_id is rejected', function () {
$pinterestAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Pinterest,
]);
$pinterestPlatform = PostPlatform::factory()->pinterest()->create([
'post_id' => $this->post->id,
'social_account_id' => $pinterestAccount->id,
'meta' => [],
]);
$mediaPayload = [
[
'id' => 'test-image',
'path' => 'media/2026-01/pin.jpg',
'url' => 'https://example.com/media/2026-01/pin.jpg',
'type' => 'image',
'mime_type' => 'image/jpeg',
'original_filename' => 'pin.jpg',
],
];
$response = $this->actingAs($this->user)
->put(route('app.posts.update', $this->post), [
'status' => Status::Scheduled->value,
'scheduled_at' => now()->addDay()->toIso8601String(),
'media' => $mediaPayload,
'platforms' => [
[
'id' => $pinterestPlatform->id,
'content_type' => ContentType::PinterestPin->value,
'meta' => [],
],
],
]);
$response->assertSessionHasErrors('platforms.0.meta.board_id');
});
test('publishing a pinterest carousel without board_id is rejected', function () {
$pinterestAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Pinterest,
]);
$pinterestPlatform = PostPlatform::factory()->pinterestCarousel()->create([
'post_id' => $this->post->id,
'social_account_id' => $pinterestAccount->id,
'meta' => [],
]);
$mediaPayload = [
[
'id' => 'img-1',
'path' => 'media/2026-01/img1.jpg',
'url' => 'https://example.com/media/2026-01/img1.jpg',
'type' => 'image',
'mime_type' => 'image/jpeg',
'original_filename' => 'img1.jpg',
],
[
'id' => 'img-2',
'path' => 'media/2026-01/img2.jpg',
'url' => 'https://example.com/media/2026-01/img2.jpg',
'type' => 'image',
'mime_type' => 'image/jpeg',
'original_filename' => 'img2.jpg',
],
];
$response = $this->actingAs($this->user)
->put(route('app.posts.update', $this->post), [
'status' => Status::Publishing->value,
'media' => $mediaPayload,
'platforms' => [
[
'id' => $pinterestPlatform->id,
'content_type' => ContentType::PinterestCarousel->value,
'meta' => [],
],
],
]);
$response->assertSessionHasErrors('platforms.0.meta.board_id');
});
test('publishing a pinterest video pin without board_id is rejected', function () {
$pinterestAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Pinterest,
]);
$pinterestPlatform = PostPlatform::factory()->pinterestVideoPin()->create([
'post_id' => $this->post->id,
'social_account_id' => $pinterestAccount->id,
'meta' => [],
]);
$response = $this->actingAs($this->user)
->put(route('app.posts.update', $this->post), [
'status' => Status::Publishing->value,
'media' => $this->mediaPayload,
'platforms' => [
[
'id' => $pinterestPlatform->id,
'content_type' => ContentType::PinterestVideoPin->value,
'meta' => [],
],
],
]);
$response->assertSessionHasErrors('platforms.0.meta.board_id');
});
test('pinterest board error does not block other platforms in multi-platform publish', function () {
$pinterestAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Pinterest,
]);
$pinterestPlatform = PostPlatform::factory()->pinterest()->create([
'post_id' => $this->post->id,
'social_account_id' => $pinterestAccount->id,
'meta' => [],
]);
$linkedinAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
]);
$linkedinPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $linkedinAccount->id,
'platform' => Platform::LinkedIn,
'content_type' => ContentType::LinkedInPost,
'meta' => [],
]);
$mediaPayload = [
[
'id' => 'test-image',
'path' => 'media/2026-01/pin.jpg',
'url' => 'https://example.com/media/2026-01/pin.jpg',
'type' => 'image',
'mime_type' => 'image/jpeg',
'original_filename' => 'pin.jpg',
],
];
$response = $this->actingAs($this->user)
->put(route('app.posts.update', $this->post), [
'status' => Status::Publishing->value,
'media' => $mediaPayload,
'platforms' => [
[
'id' => $pinterestPlatform->id,
'content_type' => ContentType::PinterestPin->value,
'meta' => [],
],
[
'id' => $linkedinPlatform->id,
'content_type' => ContentType::LinkedInPost->value,
'meta' => [],
],
],
]);
$response->assertSessionHasErrors('platforms.0.meta.board_id');
$response->assertSessionDoesntHaveErrors(['platforms.1.meta.board_id']);
});
test('saving a pinterest post as draft without board_id skips the board rule', function () {
$pinterestAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Pinterest,
]);
$pinterestPlatform = PostPlatform::factory()->pinterest()->create([
'post_id' => $this->post->id,
'social_account_id' => $pinterestAccount->id,
'meta' => [],
]);
$response = $this->actingAs($this->user)
->put(route('app.posts.update', $this->post), [
'status' => Status::Draft->value,
'platforms' => [
[
'id' => $pinterestPlatform->id,
'content_type' => ContentType::PinterestPin->value,
'meta' => [],
],
],
]);
$response->assertSessionDoesntHaveErrors(['platforms.0.meta.board_id']);
});
test('saving a tiktok post as draft without privacy_level skips the privacy_level rule', function () {
$response = $this->actingAs($this->user)
->put(route('app.posts.update', $this->post), [