Merge pull request #39 from trypostit/fix/pinterest-board-picker

fix(pinterest): restore board picker + require board_id in validation
This commit is contained in:
Paulo Castellano 2026-05-15 12:07:13 -03:00 committed by GitHub
commit 4cd0b642e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 465 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

@ -1,11 +1,24 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import InputError from '@/components/InputError.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';
import type { PinterestBoard } from '@/types';
interface SocialAccount {
id: string;
@ -15,10 +28,17 @@ interface SocialAccount {
avatar_url: string | null;
}
interface BoardOption {
value: string;
label: string;
}
interface Props {
socialAccount: SocialAccount | null;
contentType: string;
media: MediaItem[];
boards: PinterestBoard[];
meta: Record<string, any>;
disabled?: boolean;
}
@ -28,6 +48,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 +65,26 @@ 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 }),
});
// Surface the backend validation error keyed by platform index
// (`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 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];
});
</script>
<template>
@ -99,6 +140,54 @@ 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>
<template v-else>
<Combobox
v-model="selectedBoard"
:display-value="(b: BoardOption | undefined) => 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 bg-card px-3 py-2 text-sm font-medium text-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="boardError ? 'border-rose-500' : 'border-foreground/30 hover:border-foreground'"
: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>
<InputError :message="boardError" />
</template>
</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

@ -5,6 +5,7 @@ import CommentsTab from '@/components/posts/editor/CommentsTab.vue';
import PreviewTab from '@/components/posts/editor/PreviewTab.vue';
import ScheduleTab from '@/components/posts/editor/ScheduleTab.vue';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { PinterestBoard } from '@/types';
interface MediaItem {
id: string;
@ -71,6 +72,7 @@ const props = defineProps<{
labels: { id: string; name: string; color: string }[];
selectedLabelIds: string[];
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
pinterestBoards?: Record<string, PinterestBoard[]> | null;
isReadOnly: boolean;
authUserId: string;
initialHighlightCommentId: string | null;
@ -127,6 +129,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

@ -13,6 +13,7 @@ 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';
import type { PinterestBoard } from '@/types';
interface SocialAccount {
id: string;
@ -86,6 +87,7 @@ const props = defineProps<{
platformContentTypes: Record<string, string>;
platformIssues?: Record<string, string>;
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
pinterestBoards?: Record<string, PinterestBoard[]> | null;
media?: MediaItem[];
}>();
@ -134,6 +136,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): PinterestBoard[] =>
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 +326,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

@ -19,6 +19,7 @@ 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';
interface MediaItem {
id: string;
@ -88,7 +89,7 @@ const props = defineProps<{
post: Post;
socialAccounts: SocialAccount[];
platformConfigs: Record<string, any>;
pinterestBoards: any[];
pinterestBoards: Record<string, PinterestBoard[]>;
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
labels: { id: string; name: string; color: string }[];
signatures: { id: string; name: string; content: string }[];
@ -276,6 +277,14 @@ const tiktokComplianceValid = computed(() => {
});
});
// 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
@ -286,6 +295,7 @@ const contentLengthOverflows = computed(() => {
const canSchedule = computed(
() => mediaCompliancePerPlatformValid.value
&& tiktokComplianceValid.value
&& pinterestComplianceValid.value
&& contentLengthOverflows.value.length === 0,
);
@ -321,6 +331,10 @@ const postActionTooltip = computed(() => {
return trans('posts.form.tiktok.compliance_incomplete');
}
if (!pinterestComplianceValid.value) {
return trans('posts.form.pinterest.board_required');
}
return trans('posts.edit.compliance_incomplete');
});
@ -576,6 +590,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

@ -90,3 +90,8 @@ export type BreadcrumbItem = {
href?: string;
};
export interface PinterestBoard {
id: string;
name: string;
}

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), [