diff --git a/app/Actions/Automation/Automation/GetAutomationEditorData.php b/app/Actions/Automation/Automation/GetAutomationEditorData.php index 157e4ee13..3db30dadf 100644 --- a/app/Actions/Automation/Automation/GetAutomationEditorData.php +++ b/app/Actions/Automation/Automation/GetAutomationEditorData.php @@ -4,10 +4,10 @@ namespace App\Actions\Automation\Automation; +use App\Actions\SocialAccount\ListPinterestBoards; use App\Enums\SocialAccount\Platform; use App\Models\Automation; use App\Models\SocialAccount; -use App\Services\Social\PinterestPublisher; use App\Services\Social\TikTokCreatorInfo; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Collection as SupportCollection; @@ -15,14 +15,13 @@ class GetAutomationEditorData { public function __construct( - private PinterestPublisher $pinterestPublisher, private TikTokCreatorInfo $tikTokCreatorInfo, ) {} /** * @return array{ * socialAccounts: Collection, - * pinterestBoards: SupportCollection>, + * pinterestBoards: SupportCollection, truncated: bool}>, * tiktokCreatorInfos: SupportCollection, * } */ @@ -34,8 +33,8 @@ public function __invoke(Automation $automation): array ->where('platform', Platform::Pinterest) ->mapWithKeys(fn ($account) => [ $account->id => rescue( - fn () => $this->pinterestPublisher->getBoards($account), - [], + fn () => ListPinterestBoards::execute($account), + ['boards' => [], 'truncated' => false], report: false, ), ]); diff --git a/app/Actions/SocialAccount/ListDiscordChannels.php b/app/Actions/SocialAccount/ListDiscordChannels.php new file mode 100644 index 000000000..9e0cebf32 --- /dev/null +++ b/app/Actions/SocialAccount/ListDiscordChannels.php @@ -0,0 +1,19 @@ + + */ + public static function execute(SocialAccount $account): array + { + return app(DiscordClient::class)->channels((string) $account->platform_user_id); + } +} diff --git a/app/Actions/SocialAccount/ListPinterestBoards.php b/app/Actions/SocialAccount/ListPinterestBoards.php new file mode 100644 index 000000000..1d7413709 --- /dev/null +++ b/app/Actions/SocialAccount/ListPinterestBoards.php @@ -0,0 +1,34 @@ +, truncated: bool} + */ + public static function execute(SocialAccount $account): array + { + $result = app(PinterestPublisher::class)->getBoards($account); + + $boards = Collection::make(data_get($result, 'boards', [])) + ->map(fn (mixed $board): array => [ + 'id' => (string) data_get($board, 'id'), + 'name' => (string) data_get($board, 'name'), + ]) + ->filter(fn (array $board): bool => $board['id'] !== '') + ->values() + ->all(); + + return [ + 'boards' => $boards, + 'truncated' => (bool) data_get($result, 'truncated', false), + ]; + } +} diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index b9338bc91..0d56e74cd 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -4,6 +4,7 @@ namespace App\Enums\PostPlatform; +use App\Enums\Media\Type as MediaType; use App\Enums\SocialAccount\Platform as SocialPlatform; enum ContentType: string @@ -179,6 +180,267 @@ public function maxMediaCount(): int }; } + /** + * Maximum video duration in seconds for this content type, when the + * platform publishes a hard cap via API. Null when unlimited, unknown, + * or enforced dynamically (e.g. TikTok creator_info). + * + * Single source of truth for web (via Inertia shared props), REST API, + * and MCP content-type listings. + */ + public function maxVideoDurationSec(): ?int + { + return match ($this) { + self::InstagramFeed => 60, + self::InstagramReel => 15 * 60, + self::InstagramStory => 60, + self::FacebookPost => 240 * 60, + self::FacebookReel => 90, + self::FacebookStory => 60, + self::LinkedInPost, self::LinkedInPagePost => 10 * 60, + self::YouTubeShort => 3 * 60, + self::PinterestVideoPin => 15 * 60, + self::XPost => 140, + self::ThreadsPost => 5 * 60, + self::BlueskyPost => 60, + default => null, + }; + } + + /** + * Minimum attachments required (0 = no floor beyond requiresMedia()). + */ + public function minMediaCount(): int + { + return match ($this) { + self::PinterestCarousel => 2, + self::TikTokPhoto => 1, + default => 0, + }; + } + + /** + * Whether animated GIFs are accepted (kept as GIF, not flattened to JPEG). + */ + public function acceptsGif(): bool + { + return match ($this) { + self::XPost, self::BlueskyPost, self::MastodonPost, + self::DiscordMessage, self::TelegramPost => true, + default => false, + }; + } + + /** + * Per-type image size cap in bytes, capped at the global upload hard limit + * (trypost.media.max_size_mb.image). Null when images are not accepted or + * the platform has no tighter editor-side limit than that hard cap. + */ + public function maxImageBytes(): ?int + { + $bytes = match ($this) { + self::InstagramFeed, self::InstagramStory => self::bytesFromMb(8), + self::FacebookPost => self::bytesFromMb(4), + self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(5), + self::PinterestPin, self::PinterestCarousel => self::bytesFromMb(20), + self::XPost => self::bytesFromMb(5), + self::ThreadsPost => self::bytesFromMb(8), + self::MastodonPost => self::bytesFromMb(10), + default => null, + }; + + return self::capToHardLimit($bytes, MediaType::Image); + } + + /** + * Per-type video size cap in bytes, capped at the global upload hard limit + * (trypost.media.max_size_mb.video). Null when videos are not accepted or + * the platform has no tighter editor-side limit than that hard cap. + */ + public function maxVideoBytes(): ?int + { + $bytes = match ($this) { + self::InstagramFeed, self::InstagramStory => self::bytesFromMb(100), + self::InstagramReel => self::bytesFromGb(1), + self::FacebookPost => self::bytesFromGb(10), + self::FacebookReel, self::FacebookStory => self::bytesFromGb(1), + self::LinkedInPost, self::LinkedInPagePost => self::bytesFromGb(5), + self::YouTubeShort => self::bytesFromGb(256), + self::PinterestVideoPin => self::bytesFromGb(2), + self::XPost => self::bytesFromMb(512), + self::ThreadsPost => self::bytesFromGb(1), + self::BlueskyPost => self::bytesFromMb(100), + self::MastodonPost => self::bytesFromMb(40), + default => null, + }; + + return self::capToHardLimit($bytes, MediaType::Video); + } + + /** + * Per-type PDF size cap in bytes, capped at the global upload hard limit. + * Null when documents are not accepted. + */ + public function maxDocumentBytes(): ?int + { + $bytes = match ($this) { + self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(100), + default => null, + }; + + return self::capToHardLimit($bytes, MediaType::Document); + } + + /** + * Soft aspect-ratio window used by the Vue cropper / media picker. + * + * @return array{min: float, max: float}|null + */ + public function aspectRatioBounds(): ?array + { + return match ($this) { + self::InstagramFeed => ['min' => 0.8, 'max' => 1.91], + self::InstagramReel, self::InstagramStory, + self::FacebookReel, self::FacebookStory, + self::YouTubeShort => ['min' => 0.5, 'max' => 0.6], + default => null, + }; + } + + /** + * Whether the editor should auto-fit still images into the story frame. + */ + public function autoFitsImage(): bool + { + return $this === self::InstagramStory; + } + + /** + * Full media-rule payload for the Vue editor (and any other consumer). + * Shared once via Inertia — do not re-hardcode these numbers in JS. + * + * @return array{ + * max_files: int, + * min_files: int|null, + * accept_images: bool, + * accept_videos: bool, + * accept_documents: bool, + * requires_media: bool, + * accepts_gif: bool, + * forbids_mixed_media: bool, + * max_image_bytes: int|null, + * max_video_bytes: int|null, + * max_document_bytes: int|null, + * max_video_duration_sec: int|null, + * aspect_ratio_min: float|null, + * aspect_ratio_max: float|null, + * auto_fits_image: bool + * } + */ + public function mediaRules(): array + { + $bounds = $this->aspectRatioBounds(); + $minFiles = $this->minMediaCount(); + + return [ + 'max_files' => $this->maxMediaCount(), + 'min_files' => $minFiles > 0 ? $minFiles : null, + 'accept_images' => $this->supportsImage(), + 'accept_videos' => $this->supportsVideo(), + 'accept_documents' => $this->supportsDocument(), + 'requires_media' => $this->requiresMedia(), + 'accepts_gif' => $this->acceptsGif(), + 'forbids_mixed_media' => ! $this->supportsMixedMedia(), + 'max_image_bytes' => $this->maxImageBytes(), + 'max_video_bytes' => $this->maxVideoBytes(), + 'max_document_bytes' => $this->maxDocumentBytes(), + 'max_video_duration_sec' => $this->maxVideoDurationSec(), + 'aspect_ratio_min' => $bounds['min'] ?? null, + 'aspect_ratio_max' => $bounds['max'] ?? null, + 'auto_fits_image' => $this->autoFitsImage(), + ]; + } + + /** + * Content-type row for REST / MCP listings (agents + API clients). + * Keep in sync with mediaRules() capability fields — do not omit mins. + * + * @return array{ + * value: string, + * label: string, + * description: string, + * max_media_count: int, + * min_media_count: int, + * requires_media: bool, + * accept_images: bool, + * accept_videos: bool, + * accept_documents: bool, + * accepts_gif: bool, + * forbids_mixed_media: bool, + * max_video_duration_sec: int|null, + * max_image_bytes: int|null, + * max_video_bytes: int|null, + * max_document_bytes: int|null + * } + */ + public function toListingArray(): array + { + return [ + 'value' => $this->value, + 'label' => $this->label(), + 'description' => $this->description(), + 'max_media_count' => $this->maxMediaCount(), + 'min_media_count' => $this->minMediaCount(), + 'requires_media' => $this->requiresMedia(), + 'accept_images' => $this->supportsImage(), + 'accept_videos' => $this->supportsVideo(), + 'accept_documents' => $this->supportsDocument(), + 'accepts_gif' => $this->acceptsGif(), + 'forbids_mixed_media' => ! $this->supportsMixedMedia(), + 'max_video_duration_sec' => $this->maxVideoDurationSec(), + 'max_image_bytes' => $this->maxImageBytes(), + 'max_video_bytes' => $this->maxVideoBytes(), + 'max_document_bytes' => $this->maxDocumentBytes(), + ]; + } + + /** + * @return array> + */ + public static function mediaRulesForFrontend(): array + { + $rules = []; + + foreach (self::cases() as $type) { + $rules[$type->value] = $type->mediaRules(); + } + + return $rules; + } + + private static function bytesFromMb(int $megabytes): int + { + return $megabytes * 1024 * 1024; + } + + private static function bytesFromGb(int $gigabytes): int + { + return $gigabytes * 1024 * 1024 * 1024; + } + + /** + * Never advertise a soft platform ceiling above what trypost.media allows + * on upload (web / API / MCP signed URL). + */ + private static function capToHardLimit(?int $platformBytes, MediaType $type): ?int + { + if ($platformBytes === null) { + return null; + } + + return min($platformBytes, $type->maxSizeInBytes()); + } + public function supportsVideo(): bool { return match ($this) { @@ -263,7 +525,6 @@ public function requiresMedia(): bool self::MastodonPost => false, self::TelegramPost => false, self::FacebookPost => false, - self::InstagramFeed => false, self::DiscordMessage => false, default => true, }; @@ -288,6 +549,7 @@ public static function aiSupported(): array self::MastodonPost, self::FacebookPost, self::PinterestPin, + self::PinterestCarousel, ]; } diff --git a/app/Http/Controllers/Api/SocialAccountController.php b/app/Http/Controllers/Api/SocialAccountController.php index b6827dc58..2b31b785a 100644 --- a/app/Http/Controllers/Api/SocialAccountController.php +++ b/app/Http/Controllers/Api/SocialAccountController.php @@ -4,7 +4,14 @@ namespace App\Http\Controllers\Api; +use App\Actions\SocialAccount\ListDiscordChannels; +use App\Actions\SocialAccount\ListPinterestBoards; use App\Actions\SocialAccount\ToggleSocialAccount; +use App\Enums\SocialAccount\Platform; +use App\Exceptions\PlatformUnavailableException; +use App\Exceptions\Social\ErrorCategory; +use App\Exceptions\Social\PinterestPublishException; +use App\Exceptions\TokenExpiredException; use App\Http\Resources\Api\SocialAccountResource; use App\Models\SocialAccount; use Illuminate\Http\JsonResponse; @@ -21,17 +28,70 @@ public function index(Request $request): AnonymousResourceCollection return SocialAccountResource::collection($accounts); } - public function toggle(Request $request, SocialAccount $account): SocialAccountResource|JsonResponse + public function toggle(Request $request, SocialAccount $account): SocialAccountResource { - if ($account->workspace_id !== $request->user()->currentWorkspace->id) { + $this->authorize('view', $account); + + ToggleSocialAccount::execute($account); + + return new SocialAccountResource($account); + } + + public function boards(Request $request, SocialAccount $account): JsonResponse + { + $this->authorize('view', $account); + + if ($account->platform !== Platform::Pinterest) { return response()->json( - ['message' => 'Account not found.'], - Response::HTTP_NOT_FOUND, + ['message' => 'Boards are only available for Pinterest accounts.'], + Response::HTTP_UNPROCESSABLE_ENTITY, ); } - ToggleSocialAccount::execute($account); + try { + return response()->json(ListPinterestBoards::execute($account)); + } catch (TokenExpiredException $e) { + return response()->json( + ['message' => $e->getMessage()], + Response::HTTP_UNAUTHORIZED, + ); + } catch (PinterestPublishException $e) { + return response()->json( + ['message' => $e->userMessage], + $this->statusForPinterestCategory($e->category), + ); + } + } - return new SocialAccountResource($account); + public function channels(Request $request, SocialAccount $account): JsonResponse + { + $this->authorize('view', $account); + + if ($account->platform !== Platform::Discord) { + return response()->json( + ['message' => 'Channels are only available for Discord accounts.'], + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + try { + return response()->json([ + 'channels' => ListDiscordChannels::execute($account), + ]); + } catch (PlatformUnavailableException $e) { + return response()->json( + ['message' => $e->getMessage()], + Response::HTTP_BAD_GATEWAY, + ); + } + } + + private function statusForPinterestCategory(ErrorCategory $category): int + { + return match ($category) { + ErrorCategory::RateLimit => Response::HTTP_TOO_MANY_REQUESTS, + ErrorCategory::Permission => Response::HTTP_FORBIDDEN, + default => Response::HTTP_BAD_GATEWAY, + }; } } diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index e6104975c..00e2720d7 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -13,11 +13,18 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\Response; +use Throwable; class UploadController extends Controller { private const CACHE_TTL_BUFFER_SECONDS = 60; + /** + * One-shot claim for a signed upload token (api.uploads.store). + * Survives across MCP and any other client that POSTs the signed URL. + */ + private const CLAIM_CACHE_PREFIX = 'media:signed-upload:'; + public function store(StoreUploadRequest $request, string $token): JsonResponse { $expiresAt = (int) $request->query('expires'); @@ -26,7 +33,9 @@ public function store(StoreUploadRequest $request, string $token): JsonResponse $expiresAt - now()->timestamp + self::CACHE_TTL_BUFFER_SECONDS, ); - if (! Cache::add("mcp_upload:{$token}", true, $ttl)) { + $cacheKey = self::CLAIM_CACHE_PREFIX.$token; + + if (! Cache::add($cacheKey, true, $ttl)) { abort(Response::HTTP_CONFLICT); } @@ -34,15 +43,36 @@ public function store(StoreUploadRequest $request, string $token): JsonResponse abort(Response::HTTP_CONFLICT); } - $workspace = Workspace::findOrFail((string) $request->query('workspace_id')); + try { + $workspace = Workspace::findOrFail((string) $request->query('workspace_id')); + $file = $request->file('media'); + $path = $file->getRealPath(); + + // Stream from PHP's temp upload path — do not load the whole file into + // memory (addMedia() uses file_get_contents; videos can be up to 1GB). + if ($path === false) { + abort(Response::HTTP_UNPROCESSABLE_ENTITY, 'Unable to read uploaded file.'); + } - $media = DB::transaction(function () use ($workspace, $request, $token): Media { - $media = $workspace->addMedia($request->file('media'), 'assets'); - $media->upload_token = $token; - $media->save(); + $media = DB::transaction(function () use ($workspace, $file, $path, $token): Media { + $media = $workspace->addMediaFromPath( + $path, + $file->getClientOriginalName(), + 'assets', + mimeType: (string) $file->getMimeType(), + ); + $media->upload_token = $token; + $media->save(); - return $media; - }); + return $media; + }); + } catch (Throwable $e) { + // Claim is only permanent after Media is stored — release so the + // signed URL can be retried after a transient disk/storage failure. + Cache::forget($cacheKey); + + throw $e; + } return MediaUploadResource::make($media) ->response() diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 7ef6f6332..c1ebc395b 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -9,6 +9,7 @@ use App\Actions\Post\DuplicatePost; use App\Actions\Post\SyncPostPlatforms; use App\Actions\Post\UpdatePost; +use App\Actions\SocialAccount\ListPinterestBoards; use App\Ai\Templates\AiContentTemplate; use App\Ai\Templates\AiTemplateRegistry; use App\Enums\Post\Action as PostAction; @@ -23,7 +24,6 @@ use App\Models\Post; use App\Models\PostPlatform; use App\Services\Post\PostMetricsFetcher; -use App\Services\Social\PinterestPublisher; use App\Services\Social\TikTokCreatorInfo; use App\Support\PostStatusRules; use Carbon\Carbon; @@ -260,8 +260,8 @@ public function edit(Request $request, Post $post): Response|RedirectResponse ->where('platform', Platform::Pinterest) ->mapWithKeys(fn ($account) => [ $account->id => rescue( - fn () => app(PinterestPublisher::class)->getBoards($account), - [], + fn () => ListPinterestBoards::execute($account), + ['boards' => [], 'truncated' => false], report: false, ), ]); diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index 5e51d6d89..167185be8 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -4,6 +4,7 @@ namespace App\Http\Middleware\App; +use App\Enums\PostPlatform\ContentType; use App\Http\Resources\App\HandleInertiaRequests\AuthAccountResource; use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource; use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource; @@ -62,4 +63,15 @@ public function share(Request $request): array 'githubAuthEnabled' => config('trypost.github_auth_enabled'), ]; } + + /** + * @return array + */ + public function shareOnce(Request $request): array + { + return [ + ...parent::shareOnce($request), + 'contentTypeMediaRules' => fn (): array => ContentType::mediaRulesForFrontend(), + ]; + } } diff --git a/app/Http/Requests/Api/StoreUploadRequest.php b/app/Http/Requests/Api/StoreUploadRequest.php index f07237ed5..4e9665522 100644 --- a/app/Http/Requests/Api/StoreUploadRequest.php +++ b/app/Http/Requests/Api/StoreUploadRequest.php @@ -6,6 +6,7 @@ use App\Enums\Media\Type as MediaType; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Validator; class StoreUploadRequest extends FormRequest { @@ -25,15 +26,42 @@ public function rules(): array MediaType::Document->allowedMimeTypes(), )); - $maxKb = (int) config('ai.mcp.upload.max_size_mb') * 1024; - return [ + // Largest per-type cap as the FormRequest upper bound; per-type + // enforcement runs in withValidator() below — same pattern as + // StoreMediaRequest + PostController::storeMedia. 'media' => [ 'required', 'file', - "max:{$maxKb}", + 'max:'.MediaType::Video->maxSizeInKb(), "mimetypes:{$allowed}", ], ]; } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $file = $this->file('media'); + + if ($file === null || $validator->errors()->isNotEmpty()) { + return; + } + + $type = MediaType::fromMime((string) $file->getMimeType()); + + if ($type === null) { + $validator->errors()->add('media', 'This file type is not supported.'); + + return; + } + + if ($file->getSize() > $type->maxSizeInBytes()) { + $validator->errors()->add( + 'media', + "File size exceeds the maximum allowed for {$type->value} ({$type->maxSizeInMb()} MB).", + ); + } + }); + } } diff --git a/app/Http/Resources/Api/PlatformContentTypesResource.php b/app/Http/Resources/Api/PlatformContentTypesResource.php index 1082de9fd..bc902a4c7 100644 --- a/app/Http/Resources/Api/PlatformContentTypesResource.php +++ b/app/Http/Resources/Api/PlatformContentTypesResource.php @@ -36,13 +36,7 @@ public function toArray(Request $request): array ), 'default_content_type' => ContentType::defaultFor($platform)->value, 'content_types' => array_map( - fn (ContentType $type) => [ - 'value' => $type->value, - 'label' => $type->label(), - 'description' => $type->description(), - 'max_media_count' => $type->maxMediaCount(), - 'requires_media' => $type->requiresMedia(), - ], + fn (ContentType $type) => $type->toListingArray(), array_values(ContentType::forPlatform($platform)), ), ]; diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php index cd2d18502..e9f709a20 100644 --- a/app/Mcp/Servers/TryPostServer.php +++ b/app/Mcp/Servers/TryPostServer.php @@ -27,6 +27,8 @@ use App\Mcp\Tools\Signature\DeleteSignatureTool; use App\Mcp\Tools\Signature\ListSignaturesTool; use App\Mcp\Tools\Signature\UpdateSignatureTool; +use App\Mcp\Tools\SocialAccount\ListDiscordChannelsTool; +use App\Mcp\Tools\SocialAccount\ListPinterestBoardsTool; use App\Mcp\Tools\SocialAccount\ListSocialAccountsTool; use App\Mcp\Tools\SocialAccount\ToggleSocialAccountTool; use App\Mcp\Tools\Workspace\GetWorkspaceTool; @@ -73,6 +75,8 @@ class TryPostServer extends Server // Social Accounts ListSocialAccountsTool::class, + ListPinterestBoardsTool::class, + ListDiscordChannelsTool::class, ToggleSocialAccountTool::class, // Workspace diff --git a/app/Mcp/Tools/Platform/ListContentTypesTool.php b/app/Mcp/Tools/Platform/ListContentTypesTool.php index a7b3b632c..fb054066c 100644 --- a/app/Mcp/Tools/Platform/ListContentTypesTool.php +++ b/app/Mcp/Tools/Platform/ListContentTypesTool.php @@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('List the valid content_types per social platform plus their constraints (max content length, recommended length, max media count, whether media is required, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')] +#[Description('List the valid content_types per social platform plus their constraints (max/min media count, whether media is required, accept_images/videos/documents/gif, forbids_mixed_media, max video duration in seconds, per-type max image/video/document bytes, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')] class ListContentTypesTool extends Tool { public function handle(Request $request): ResponseFactory @@ -23,13 +23,7 @@ public function handle(Request $request): ResponseFactory foreach (Platform::cases() as $platform) { $contentTypes = array_map( - fn (ContentType $type) => [ - 'value' => $type->value, - 'label' => $type->label(), - 'description' => $type->description(), - 'max_media_count' => $type->maxMediaCount(), - 'requires_media' => $type->requiresMedia(), - ], + fn (ContentType $type) => $type->toListingArray(), array_values(ContentType::forPlatform($platform)), ); diff --git a/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php b/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php index c8dc44329..aa0adcf7c 100644 --- a/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php +++ b/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php @@ -58,7 +58,7 @@ public function schema(JsonSchema $schema): array 'alt' => $u->string()->description('Optional accessibility alt text for the image (ignored for video/PDF, which have no alt text).'), ])) ->required() - ->description('Media to attach. Max 10 per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'), + ->description('Media to attach. Max 10 per call. Per-type size caps match config trypost.media (image/video/document). Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'), ]; } } diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index 50c1ce08d..73730b92a 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -63,7 +63,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'), 'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'), - 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), + 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first). Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), ])) ->description('Platforms to publish on. Accounts not listed remain available but disabled.'), ]; diff --git a/app/Mcp/Tools/Post/RequestMediaUploadTool.php b/app/Mcp/Tools/Post/RequestMediaUploadTool.php index 2b380e507..6585016a2 100644 --- a/app/Mcp/Tools/Post/RequestMediaUploadTool.php +++ b/app/Mcp/Tools/Post/RequestMediaUploadTool.php @@ -4,6 +4,7 @@ namespace App\Mcp\Tools\Post; +use App\Enums\Media\Type as MediaType; use Carbon\CarbonImmutable; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\Facades\URL; @@ -14,7 +15,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image, video, or PDF document, up to the workspace upload cap — 50 MB by default) directly to this workspace. Returns an upload_token and upload_url. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')] +#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image, video, or PDF document) directly to this workspace. Size caps match web/API media limits — see max_bytes_by_type (max_bytes is the overall ceiling, equal to the video cap). Returns an upload_token, upload_url, max_bytes, and max_bytes_by_type. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')] class RequestMediaUploadTool extends Tool { public function handle(Request $request): Response|ResponseFactory @@ -22,8 +23,7 @@ public function handle(Request $request): Response|ResponseFactory $user = $request->user(); $workspaceId = $user->current_workspace_id; - $maxBytes = (int) config('ai.mcp.upload.max_size_mb') * 1024 * 1024; - $ttlMinutes = (int) config('ai.mcp.upload.url_ttl_minutes'); + $ttlMinutes = (int) config('trypost.media.signed_upload_url_ttl_minutes'); $token = (string) Str::uuid(); $expiresAt = CarbonImmutable::now()->addMinutes($ttlMinutes); @@ -38,7 +38,12 @@ public function handle(Request $request): Response|ResponseFactory 'upload_token' => $token, 'upload_url' => $uploadUrl, 'expires_at' => $expiresAt->toIso8601String(), - 'max_bytes' => $maxBytes, + 'max_bytes' => MediaType::Video->maxSizeInBytes(), + 'max_bytes_by_type' => [ + 'image' => MediaType::Image->maxSizeInBytes(), + 'video' => MediaType::Video->maxSizeInBytes(), + 'document' => MediaType::Document->maxSizeInBytes(), + ], 'field_name' => 'media', ]); } diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 18dea6c55..46e643b64 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -105,7 +105,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'), 'content_type' => $p->string()->description('New content_type for this platform.'), - 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions, embeds. Merged with existing meta.'), + 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first). Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta.'), ])) ->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'), ]; diff --git a/app/Mcp/Tools/SocialAccount/ListDiscordChannelsTool.php b/app/Mcp/Tools/SocialAccount/ListDiscordChannelsTool.php new file mode 100644 index 000000000..704c4c479 --- /dev/null +++ b/app/Mcp/Tools/SocialAccount/ListDiscordChannelsTool.php @@ -0,0 +1,53 @@ +validate([ + 'account_id' => ['required', 'string', 'uuid'], + ]); + + $account = SocialAccount::where('workspace_id', $request->user()->current_workspace_id) + ->find(data_get($validated, 'account_id')); + + if (! $account) { + return Response::error('Social account not found.'); + } + + if ($account->platform !== Platform::Discord) { + return Response::error('This tool only works with Discord social accounts.'); + } + + try { + return Response::structured([ + 'channels' => ListDiscordChannels::execute($account), + ]); + } catch (PlatformUnavailableException $e) { + return Response::error($e->getMessage()); + } + } + + public function schema(JsonSchema $schema): array + { + return [ + 'account_id' => $schema->string()->required()->description('The UUID of the connected Discord social account.'), + ]; + } +} diff --git a/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php b/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php new file mode 100644 index 000000000..2227a193f --- /dev/null +++ b/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php @@ -0,0 +1,54 @@ +validate([ + 'account_id' => ['required', 'string', 'uuid'], + ]); + + $account = SocialAccount::where('workspace_id', $request->user()->current_workspace_id) + ->find(data_get($validated, 'account_id')); + + if (! $account) { + return Response::error('Social account not found.'); + } + + if ($account->platform !== Platform::Pinterest) { + return Response::error('This tool only works with Pinterest social accounts.'); + } + + try { + return Response::structured(ListPinterestBoards::execute($account)); + } catch (TokenExpiredException $e) { + return Response::error($e->getMessage()); + } catch (PinterestPublishException $e) { + return Response::error($e->userMessage); + } + } + + public function schema(JsonSchema $schema): array + { + return [ + 'account_id' => $schema->string()->required()->description('The UUID of the connected Pinterest social account.'), + ]; + } +} diff --git a/app/Models/Traits/HasMedia.php b/app/Models/Traits/HasMedia.php index da4b39eb8..8c41e0a99 100644 --- a/app/Models/Traits/HasMedia.php +++ b/app/Models/Traits/HasMedia.php @@ -16,6 +16,7 @@ use Intervention\Image\Drivers\Gd\Driver; use Intervention\Image\Encoders\JpegEncoder; use Intervention\Image\ImageManager; +use InvalidArgumentException; use RuntimeException; trait HasMedia @@ -109,13 +110,21 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr * Add media from a file path (used for chunked uploads). * Images are normalized in memory; videos/PDFs are streamed to storage. */ - public function addMediaFromPath(string $filePath, string $originalFilename, string $collection = 'default', array $meta = [], ?string $groupId = null): Media + public function addMediaFromPath(string $filePath, string $originalFilename, string $collection = 'default', array $meta = [], ?string $groupId = null, ?string $mimeType = null): Media { if ($this->isSingleMediaCollection($collection)) { $this->clearMediaCollection($collection); } - $mimeType = mime_content_type($filePath); + // Prefer an explicit MIME (e.g. from UploadedFile after FormRequest + // validation) — mime_content_type() misclassifies empty test fakes and + // some freshly written temps as application/x-empty. + $mimeType ??= mime_content_type($filePath) ?: null; + + if ($mimeType === null) { + throw new InvalidArgumentException("Unable to determine MIME type for media file: {$filePath}"); + } + $type = $this->getMediaType($mimeType); $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); @@ -239,7 +248,7 @@ private function streamFileToStorage(string $filePath, string $mimeType, string private function getMediaType(string $mimeType): string { return (Type::classify($mimeType) - ?? throw new \InvalidArgumentException("Unsupported media MIME type: {$mimeType}"))->value; + ?? throw new InvalidArgumentException("Unsupported media MIME type: {$mimeType}"))->value; } private function getMediaMeta(UploadedFile $file, string $type): array diff --git a/app/Policies/SocialAccountPolicy.php b/app/Policies/SocialAccountPolicy.php new file mode 100644 index 000000000..94422b759 --- /dev/null +++ b/app/Policies/SocialAccountPolicy.php @@ -0,0 +1,26 @@ +workspace_id !== $user->current_workspace_id) { + return Response::denyAsNotFound(); + } + + return true; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 960c0244b..61349bc7c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -159,6 +159,28 @@ protected function configureRateLimiting(): void return Limit::perMinute(60)->by($request->workspace?->id ?: $request->ip()); }); + + // Signed media uploads (api.uploads.store). MCP hosts share egress IPs + // across tenants — key by workspace_id from the signed URL, with a high + // IP backstop so one client cannot flood every workspace. + RateLimiter::for('signed-uploads', function (Request $request) { + $limits = [ + Limit::perMinute((int) config('trypost.media.signed_upload_per_ip_per_minute')) + ->by("ip:{$request->ip()}"), + ]; + + $workspaceId = $request->query('workspace_id'); + + if (filled($workspaceId)) { + array_unshift( + $limits, + Limit::perMinute((int) config('trypost.media.signed_upload_per_workspace_per_minute')) + ->by("workspace:{$workspaceId}"), + ); + } + + return $limits; + }); } protected function configureStripeWebhooks(): void diff --git a/app/Services/Automation/GenerateNodeValidator.php b/app/Services/Automation/GenerateNodeValidator.php index e04991b2e..0ca56ed3e 100644 --- a/app/Services/Automation/GenerateNodeValidator.php +++ b/app/Services/Automation/GenerateNodeValidator.php @@ -54,12 +54,20 @@ public function issueFor(array $config): ?string private function issueForAccount(ContentType $contentType, int $imageCount): ?string { - if ($contentType->requiresMedia() && $imageCount === 0) { - return __('posts.edit.compliance.requires_media'); + // Generate only produces images — video-only formats (Reel, Video Pin, …) + // can never be satisfied by this node. + if (! $contentType->supportsImage()) { + return __('automations.errors.generate_image_format_required'); } - if ($imageCount > 0 && ! $contentType->supportsImage()) { - return __('posts.edit.compliance.no_images'); + $min = $contentType->minMediaCount(); + + if ($min > 0 && $imageCount < $min) { + return __('posts.edit.compliance.too_few_files', ['min' => (string) $min]); + } + + if ($contentType->requiresMedia() && $imageCount === 0) { + return __('posts.edit.compliance.requires_media'); } $max = min(self::MAX_GENERATED_IMAGES, $contentType->maxMediaCount()); diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index 2d97128d0..87dfb1d5c 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -405,7 +405,10 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId, } /** - * Get user's boards for board selection. + * Get user's boards for board selection (follows Pinterest bookmark pagination + * until the API stops returning a bookmark). + * + * @return array{boards: list>, truncated: bool} */ public function getBoards(SocialAccount $account): array { @@ -413,17 +416,72 @@ public function getBoards(SocialAccount $account): array app(ConnectionVerifier::class)->refreshToken($account); } - $response = $this->socialHttp()->withToken($account->access_token) - ->get($this->baseUrl.'/boards', [ - 'page_size' => 100, - ]); + $boards = []; + $bookmark = null; + $pages = 0; + $truncated = false; + // Absurd ceiling only — normal accounts exit when bookmark is blank. + $maxPages = 1000; + + while (true) { + $pages++; + + if ($pages > $maxPages) { + Log::warning('Pinterest get boards hit safety page cap', [ + 'account_id' => $account->id, + 'pages' => $pages, + 'boards' => count($boards), + ]); + $truncated = true; - if ($response->failed()) { - Log::error('Pinterest get boards failed', ['body' => $this->redactResponseBody($response->body())]); - $this->handleApiError($response); + break; + } + + $query = ['page_size' => 100]; + + if (filled($bookmark)) { + $query['bookmark'] = $bookmark; + } + + $response = $this->socialHttp()->withToken($account->access_token) + ->get($this->baseUrl.'/boards', $query); + + if ($response->failed()) { + Log::error('Pinterest get boards failed', ['body' => $this->redactResponseBody($response->body())]); + $this->handleApiError($response); + } + + $payload = $response->json() ?? []; + $items = data_get($payload, 'items', []); + + if (is_array($items) && $items !== []) { + array_push($boards, ...$items); + } + + $nextBookmark = data_get($payload, 'bookmark'); + + if (blank($nextBookmark)) { + break; + } + + if ($nextBookmark === $bookmark) { + Log::warning('Pinterest get boards returned a repeated bookmark', [ + 'account_id' => $account->id, + 'pages' => $pages, + 'boards' => count($boards), + ]); + $truncated = true; + + break; + } + + $bookmark = $nextBookmark; } - return $response->json()['items'] ?? []; + return [ + 'boards' => $boards, + 'truncated' => $truncated, + ]; } private function handleApiError(Response $response): never diff --git a/config/ai.php b/config/ai.php index c28a3160f..1a393ff01 100644 --- a/config/ai.php +++ b/config/ai.php @@ -39,25 +39,6 @@ ], ], - /* - |-------------------------------------------------------------------------- - | MCP Media Upload - |-------------------------------------------------------------------------- - | - | Settings for the MCP signed-URL upload flow. The hard cap is enforced - | per upload regardless of media type and is intentionally smaller than - | the per-type caps in config('trypost.media') because uploads flow - | through the app server. - | - */ - - 'mcp' => [ - 'upload' => [ - 'max_size_mb' => (int) env('MCP_UPLOAD_MAX_SIZE_MB', 50), - 'url_ttl_minutes' => (int) env('MCP_UPLOAD_URL_TTL_MINUTES', 15), - ], - ], - /* |-------------------------------------------------------------------------- | AI Providers diff --git a/config/trypost.php b/config/trypost.php index 96749e8ae..08cd5054a 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -56,6 +56,16 @@ | uploads (StoreAssetRequest, AssetController::storeChunked), URL | fetches (MediaAttacher), and the MediaType enum all read from here. | + | signed_upload_url_ttl_minutes controls the temporary signed POST URL + | issued for api.uploads.store (MCP / direct upload flow). + | MEDIA_SIGNED_UPLOAD_URL_TTL_MINUTES is preferred; MCP_UPLOAD_URL_TTL_MINUTES + | remains as a legacy fallback. MCP_UPLOAD_MAX_SIZE_MB was removed — size + | caps come from max_size_mb above (uploads stream to storage). + | + | signed_upload_per_*_per_minute backs the signed-uploads rate limiter: + | workspace bucket first (tenants isolated on shared MCP egress), then a + | high IP backstop. + | */ 'media' => [ @@ -65,6 +75,9 @@ // LinkedIn caps document (PDF carousel) uploads at 100MB. 'document' => (int) env('MEDIA_DOCUMENT_MAX_SIZE_MB', 100), ], + 'signed_upload_url_ttl_minutes' => (int) (env('MEDIA_SIGNED_UPLOAD_URL_TTL_MINUTES') ?? env('MCP_UPLOAD_URL_TTL_MINUTES', 15)), + 'signed_upload_per_workspace_per_minute' => (int) env('MEDIA_SIGNED_UPLOAD_PER_WORKSPACE_PER_MINUTE', 60), + 'signed_upload_per_ip_per_minute' => (int) env('MEDIA_SIGNED_UPLOAD_PER_IP_PER_MINUTE', 1200), ], /* diff --git a/lang/ar/automations.php b/lang/ar/automations.php index 229fedbc9..b2ca859b9 100644 --- a/lang/ar/automations.php +++ b/lang/ar/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'أطلق طلب HTTP استثناءً.', 'http_request_failed' => 'فشل طلب HTTP.', 'http_items_path_not_array' => 'لم يُفضِ مسار العناصر إلى قائمة.', + 'generate_image_format_required' => 'توليد الذكاء الاصطناعي ينشئ صورًا فقط. اختر تنسيق صورة (وليس فيديو).', ], ]; diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 03d07a145..3f3b800f6 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -154,6 +154,7 @@ 'board' => 'اللوحة', 'select_board' => 'اختر لوحة', 'no_boards' => 'لم يتم العثور على لوحات Pinterest. أنشئ واحدة في حسابك على Pinterest أولًا.', + 'boards_truncated' => 'تعذر تحميل بعض اللوحات. إذا كانت لوحتك مفقودة، افتح Pinterest وحاول مرة أخرى.', 'search_board' => 'البحث في اللوحات...', 'no_board_found' => 'لا توجد لوحة مطابقة لبحثك.', 'board_required' => 'اختر لوحة Pinterest لنشر هذا المنشور.', @@ -464,7 +465,7 @@ ], 'instagram_reel' => [ 'label' => 'ريل', - 'description' => 'فيديو قصير حتى 90 ثانية', + 'description' => 'فيديو قصير حتى 15 دقيقة', ], 'instagram_story' => [ 'label' => 'قصة', diff --git a/lang/de/automations.php b/lang/de/automations.php index 69c5ca546..528cbffe4 100644 --- a/lang/de/automations.php +++ b/lang/de/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'Die HTTP-Anfrage hat eine Ausnahme ausgelöst.', 'http_request_failed' => 'Die HTTP-Anfrage ist fehlgeschlagen.', 'http_items_path_not_array' => 'Der Pfad zu den Einträgen ergab keine Liste.', + 'generate_image_format_required' => 'KI-Generierung erstellt nur Bilder. Wähle ein Bildformat (kein Video).', ], ]; diff --git a/lang/de/posts.php b/lang/de/posts.php index f876ef0e3..de87cbeda 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -156,6 +156,7 @@ 'board' => 'Pinnwand', 'select_board' => 'Pinnwand auswählen', 'no_boards' => 'Keine Pinterest-Pinnwände gefunden. Erstelle zuerst eine in deinem Pinterest-Konto.', + 'boards_truncated' => 'Einige Pinnwände konnten nicht geladen werden. Fehlt deine, öffne Pinterest und versuche es erneut.', 'search_board' => 'Pinnwände suchen...', 'no_board_found' => 'Keine Pinnwand passt zu deiner Suche.', 'board_required' => 'Wähle eine Pinterest-Pinnwand, um diesen Beitrag zu veröffentlichen.', @@ -466,7 +467,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Kurzes Video bis zu 90 Sekunden', + 'description' => 'Kurzes Video bis zu 15 Minuten', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/el/automations.php b/lang/el/automations.php index d40fffbc3..da46f16ce 100644 --- a/lang/el/automations.php +++ b/lang/el/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'Το αίτημα HTTP προκάλεσε εξαίρεση.', 'http_request_failed' => 'Το αίτημα HTTP απέτυχε.', 'http_items_path_not_array' => 'Η διαδρομή στοιχείων δεν αντιστοιχήθηκε σε λίστα.', + 'generate_image_format_required' => 'Η δημιουργία AI παράγει μόνο εικόνες. Επίλεξε μορφή εικόνας (όχι βίντεο).', ], ]; diff --git a/lang/el/posts.php b/lang/el/posts.php index 975fbdaed..03e36c604 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -154,6 +154,7 @@ 'board' => 'Πίνακας', 'select_board' => 'Επιλέξτε έναν πίνακα', 'no_boards' => 'Δεν βρέθηκαν πίνακες Pinterest. Δημιουργήστε πρώτα έναν στον λογαριασμό σας Pinterest.', + 'boards_truncated' => 'Ορισμένοι πίνακες δεν φορτώθηκαν. Αν λείπει ο δικός σας, ανοίξτε το Pinterest και δοκιμάστε ξανά.', 'search_board' => 'Αναζήτηση πινάκων...', 'no_board_found' => 'Κανένας πίνακας δεν ταιριάζει με την αναζήτησή σας.', 'board_required' => 'Επιλέξτε έναν πίνακα Pinterest για να δημοσιεύσετε αυτή τη δημοσίευση.', @@ -464,7 +465,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Σύντομο βίντεο έως 90 δευτερόλεπτα', + 'description' => 'Σύντομο βίντεο έως 15 λεπτά', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/en/automations.php b/lang/en/automations.php index ecc6b353c..aa10e72bb 100644 --- a/lang/en/automations.php +++ b/lang/en/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'The HTTP request threw an exception.', 'http_request_failed' => 'The HTTP request failed.', 'http_items_path_not_array' => 'The items path did not resolve to a list.', + 'generate_image_format_required' => 'AI generate only creates images. Pick an image format (not video).', ], ]; diff --git a/lang/en/posts.php b/lang/en/posts.php index dde760132..8878a7078 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -154,6 +154,7 @@ 'board' => 'Board', 'select_board' => 'Select a board', 'no_boards' => 'No Pinterest boards found. Create one in your Pinterest account first.', + 'boards_truncated' => 'Some boards could not be loaded. If yours is missing, open Pinterest and try again.', 'search_board' => 'Search boards...', 'no_board_found' => 'No board matches your search.', 'board_required' => 'Select a Pinterest board to publish this post.', @@ -464,7 +465,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Short video up to 90 seconds', + 'description' => 'Short video up to 15 minutes', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/es/automations.php b/lang/es/automations.php index 33df62550..b8a05cd3a 100644 --- a/lang/es/automations.php +++ b/lang/es/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'La petición HTTP lanzó una excepción.', 'http_request_failed' => 'La petición HTTP falló.', 'http_items_path_not_array' => 'El items path no resolvió a una lista.', + 'generate_image_format_required' => 'La generación con IA solo crea imágenes. Elige un formato de imagen (no vídeo).', ], ]; diff --git a/lang/es/posts.php b/lang/es/posts.php index 6f53b10f4..9e00e3253 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -154,6 +154,7 @@ 'board' => 'Tablero', 'select_board' => 'Selecciona un tablero', 'no_boards' => 'No se encontraron tableros de Pinterest. Crea uno en tu cuenta de Pinterest primero.', + 'boards_truncated' => 'No se pudieron cargar algunos tableros. Si falta el tuyo, abre Pinterest e inténtalo de nuevo.', '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.', @@ -464,7 +465,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Video corto de hasta 90 segundos', + 'description' => 'Video corto de hasta 15 minutos', ], 'instagram_story' => [ 'label' => 'Historia', diff --git a/lang/fr/automations.php b/lang/fr/automations.php index 835282c87..28e92a23f 100644 --- a/lang/fr/automations.php +++ b/lang/fr/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'La requête HTTP a levé une exception.', 'http_request_failed' => 'La requête HTTP a échoué.', 'http_items_path_not_array' => 'Le chemin des éléments ne correspond pas à une liste.', + 'generate_image_format_required' => 'La génération IA ne crée que des images. Choisissez un format image (pas vidéo).', ], ]; diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 2887da406..79630288e 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -154,6 +154,7 @@ 'board' => 'Tableau', 'select_board' => 'Sélectionner un tableau', 'no_boards' => 'Aucun tableau Pinterest trouvé. Créez-en un dans votre compte Pinterest d\'abord.', + 'boards_truncated' => 'Certains tableaux n’ont pas pu être chargés. S’il manque le vôtre, ouvrez Pinterest et réessayez.', 'search_board' => 'Rechercher des tableaux...', 'no_board_found' => 'Aucun tableau ne correspond à votre recherche.', 'board_required' => 'Sélectionnez un tableau Pinterest pour publier cette publication.', @@ -464,7 +465,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Courte vidéo jusqu\'à 90 secondes', + 'description' => 'Courte vidéo jusqu\'à 15 minutes', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/it/automations.php b/lang/it/automations.php index 374902874..2d01db01c 100644 --- a/lang/it/automations.php +++ b/lang/it/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'La richiesta HTTP ha generato un\'eccezione.', 'http_request_failed' => 'La richiesta HTTP non è riuscita.', 'http_items_path_not_array' => 'Il percorso degli elementi non ha restituito un elenco.', + 'generate_image_format_required' => 'La generazione AI crea solo immagini. Scegli un formato immagine (non video).', ], ]; diff --git a/lang/it/posts.php b/lang/it/posts.php index 6baf99966..3f0d43e0e 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -154,6 +154,7 @@ 'board' => 'Bacheca', 'select_board' => 'Seleziona una bacheca', 'no_boards' => 'Nessuna bacheca Pinterest trovata. Creane una nel tuo account Pinterest prima.', + 'boards_truncated' => 'Alcune bacheche non sono state caricate. Se manca la tua, apri Pinterest e riprova.', 'search_board' => 'Cerca bacheche...', 'no_board_found' => 'Nessuna bacheca corrisponde alla ricerca.', 'board_required' => 'Seleziona una bacheca Pinterest per pubblicare questo post.', @@ -464,7 +465,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Video breve fino a 90 secondi', + 'description' => 'Video breve fino a 15 minuti', ], 'instagram_story' => [ 'label' => 'Storia', diff --git a/lang/ja/automations.php b/lang/ja/automations.php index 96f676418..0fd2f6cea 100644 --- a/lang/ja/automations.php +++ b/lang/ja/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'HTTP リクエストで例外が発生しました。', 'http_request_failed' => 'HTTP リクエストが失敗しました。', 'http_items_path_not_array' => 'アイテムのパスがリストとして解決されませんでした。', + 'generate_image_format_required' => 'AI生成は画像のみ作成します。画像フォーマットを選んでください(動画不可)。', ], ]; diff --git a/lang/ja/posts.php b/lang/ja/posts.php index 352e6ddd5..53cc227a4 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -154,6 +154,7 @@ 'board' => 'ボード', 'select_board' => 'ボードを選択', 'no_boards' => 'Pinterest ボードが見つかりません。先に Pinterest アカウントで作成してください。', + 'boards_truncated' => '一部のボードを読み込めませんでした。見つからない場合は Pinterest を開いて再試行してください。', 'search_board' => 'ボードを検索...', 'no_board_found' => '検索に一致するボードがありません。', 'board_required' => 'この投稿を公開する Pinterest ボードを選択してください。', @@ -464,7 +465,7 @@ ], 'instagram_reel' => [ 'label' => 'リール', - 'description' => '最大 90 秒のショート動画', + 'description' => '最大 15 分のショート動画', ], 'instagram_story' => [ 'label' => 'ストーリー', diff --git a/lang/ko/automations.php b/lang/ko/automations.php index 88af31f9f..420b941f6 100644 --- a/lang/ko/automations.php +++ b/lang/ko/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'HTTP 요청에서 예외가 발생했습니다.', 'http_request_failed' => 'HTTP 요청이 실패했습니다.', 'http_items_path_not_array' => '항목 경로가 목록으로 해석되지 않았습니다.', + 'generate_image_format_required' => 'AI 생성은 이미지만 만듭니다. 이미지 형식을 선택하세요(동영상 불가).', ], ]; diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 86f1f1ab5..8bb2ae08e 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -154,6 +154,7 @@ 'board' => '보드', 'select_board' => '보드 선택', 'no_boards' => 'Pinterest 보드를 찾을 수 없습니다. 먼저 Pinterest 계정에서 보드를 만드세요.', + 'boards_truncated' => '일부 보드를 불러오지 못했습니다. 내 보드가 없다면 Pinterest를 연 뒤 다시 시도하세요.', 'search_board' => '보드 검색...', 'no_board_found' => '검색과 일치하는 보드가 없습니다.', 'board_required' => '이 게시물을 게시할 Pinterest 보드를 선택하세요.', @@ -464,7 +465,7 @@ ], 'instagram_reel' => [ 'label' => '릴스', - 'description' => '최대 90초 짧은 동영상', + 'description' => '최대 15분 짧은 동영상', ], 'instagram_story' => [ 'label' => '스토리', diff --git a/lang/nl/automations.php b/lang/nl/automations.php index 8a0c082a4..eb5fde1cb 100644 --- a/lang/nl/automations.php +++ b/lang/nl/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'Het HTTP-verzoek gaf een uitzondering.', 'http_request_failed' => 'Het HTTP-verzoek is mislukt.', 'http_items_path_not_array' => 'Het itempad verwees niet naar een lijst.', + 'generate_image_format_required' => 'AI-generatie maakt alleen afbeeldingen. Kies een afbeeldingsformaat (geen video).', ], ]; diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 75a6af89a..eaa2335da 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -154,6 +154,7 @@ 'board' => 'Bord', 'select_board' => 'Selecteer een bord', 'no_boards' => 'Geen Pinterest-borden gevonden. Maak er eerst een aan in je Pinterest-account.', + 'boards_truncated' => 'Sommige borden konden niet worden geladen. Ontbreekt die van jou, open Pinterest en probeer opnieuw.', 'search_board' => 'Borden zoeken...', 'no_board_found' => 'Geen bord komt overeen met je zoekopdracht.', 'board_required' => 'Selecteer een Pinterest-bord om deze post te publiceren.', @@ -464,7 +465,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Korte video tot 90 seconden', + 'description' => 'Korte video tot 15 minuten', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/pl/automations.php b/lang/pl/automations.php index d40b87c8c..5a8984710 100644 --- a/lang/pl/automations.php +++ b/lang/pl/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'Żądanie HTTP zgłosiło wyjątek.', 'http_request_failed' => 'Żądanie HTTP nie powiodło się.', 'http_items_path_not_array' => 'Ścieżka do elementów nie zwróciła listy.', + 'generate_image_format_required' => 'Generowanie AI tworzy tylko obrazy. Wybierz format obrazu (nie wideo).', ], ]; diff --git a/lang/pl/posts.php b/lang/pl/posts.php index c30419ecc..74271f4ab 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -154,6 +154,7 @@ 'board' => 'Tablica', 'select_board' => 'Wybierz tablicę', 'no_boards' => 'Nie znaleziono tablic Pinterest. Najpierw utwórz jedną na swoim koncie Pinterest.', + 'boards_truncated' => 'Nie udało się wczytać niektórych tablic. Jeśli brakuje twojej, otwórz Pinterest i spróbuj ponownie.', 'search_board' => 'Szukaj tablic...', 'no_board_found' => 'Brak tablic pasujących do wyszukiwania.', 'board_required' => 'Wybierz tablicę Pinterest, aby opublikować ten post.', @@ -464,7 +465,7 @@ ], 'instagram_reel' => [ 'label' => 'Rolka', - 'description' => 'Krótki film do 90 sekund', + 'description' => 'Krótki film do 15 minut', ], 'instagram_story' => [ 'label' => 'Relacja', diff --git a/lang/pt-BR/automations.php b/lang/pt-BR/automations.php index 7b4bdfcfd..3603ca2ac 100644 --- a/lang/pt-BR/automations.php +++ b/lang/pt-BR/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'A requisição HTTP lançou uma exceção.', 'http_request_failed' => 'A requisição HTTP falhou.', 'http_items_path_not_array' => 'O items path não resultou em uma lista.', + 'generate_image_format_required' => 'A geração por IA só cria imagens. Escolha um formato de imagem (não vídeo).', ], ]; diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 513599545..8d40e9611 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -154,6 +154,7 @@ 'board' => 'Quadro', 'select_board' => 'Selecione um quadro', 'no_boards' => 'Nenhum quadro do Pinterest encontrado. Crie um na sua conta do Pinterest primeiro.', + 'boards_truncated' => 'Alguns boards não puderam ser carregados. Se o seu não aparecer, abra o Pinterest e tente de novo.', 'search_board' => 'Pesquisar quadros...', 'no_board_found' => 'Nenhum quadro encontrado.', 'board_required' => 'Selecione um quadro do Pinterest para publicar este post.', @@ -464,7 +465,7 @@ ], 'instagram_reel' => [ 'label' => 'Reels', - 'description' => 'Vídeo curto de até 90 segundos', + 'description' => 'Vídeo curto de até 15 minutos', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/ru/automations.php b/lang/ru/automations.php index 2c059b83c..1de196ba1 100644 --- a/lang/ru/automations.php +++ b/lang/ru/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'HTTP-запрос вызвал исключение.', 'http_request_failed' => 'HTTP-запрос не удался.', 'http_items_path_not_array' => 'Путь к элементам не привёл к списку.', + 'generate_image_format_required' => 'ИИ генерирует только изображения. Выберите формат изображения (не видео).', ], ]; diff --git a/lang/ru/posts.php b/lang/ru/posts.php index c97df56c6..a2efc84e5 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -154,6 +154,7 @@ 'board' => 'Доска', 'select_board' => 'Выберите доску', 'no_boards' => 'Доски Pinterest не найдены. Сначала создайте доску в своём аккаунте Pinterest.', + 'boards_truncated' => 'Некоторые доски не удалось загрузить. Если вашей нет, откройте Pinterest и попробуйте снова.', 'search_board' => 'Поиск досок...', 'no_board_found' => 'Нет досок по вашему запросу.', 'board_required' => 'Выберите доску Pinterest, чтобы опубликовать этот пост.', @@ -464,7 +465,7 @@ ], 'instagram_reel' => [ 'label' => 'Reels', - 'description' => 'Короткое видео до 90 секунд', + 'description' => 'Короткое видео до 15 минут', ], 'instagram_story' => [ 'label' => 'История', diff --git a/lang/tr/automations.php b/lang/tr/automations.php index a033f4f9d..402d06c3a 100644 --- a/lang/tr/automations.php +++ b/lang/tr/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'HTTP isteği bir istisna oluşturdu.', 'http_request_failed' => 'HTTP isteği başarısız oldu.', 'http_items_path_not_array' => 'Öğe yolu bir listeye çözümlenmedi.', + 'generate_image_format_required' => 'AI oluşturma yalnızca görsel üretir. Görsel formatı seçin (video değil).', ], ]; diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 59007cc6d..a70247885 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -156,6 +156,7 @@ 'board' => 'Pano', 'select_board' => 'Bir pano seçin', 'no_boards' => 'Pinterest panosu bulunamadı. Önce Pinterest hesabınızda bir tane oluşturun.', + 'boards_truncated' => 'Bazı panolar yüklenemedi. Sizinki eksikse Pinterest’i açıp tekrar deneyin.', 'search_board' => 'Pano ara...', 'no_board_found' => 'Aramanızla eşleşen pano yok.', 'board_required' => 'Bu gönderiyi yayınlamak için bir Pinterest panosu seçin.', @@ -466,7 +467,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => '90 saniyeye kadar kısa video', + 'description' => '15 dakikaya kadar kısa video', ], 'instagram_story' => [ 'label' => 'Hikaye', diff --git a/lang/zh/automations.php b/lang/zh/automations.php index b1643e2dc..e8bbb4e56 100644 --- a/lang/zh/automations.php +++ b/lang/zh/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'HTTP 请求抛出了异常。', 'http_request_failed' => 'HTTP 请求失败。', 'http_items_path_not_array' => '条目路径未解析为列表。', + 'generate_image_format_required' => 'AI 生成仅创建图片。请选择图片格式(不支持视频)。', ], ]; diff --git a/lang/zh/posts.php b/lang/zh/posts.php index fc5d3cb3d..9cf3a6a71 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -154,6 +154,7 @@ 'board' => '图板', 'select_board' => '选择一个图板', 'no_boards' => '未找到 Pinterest 图板。请先在你的 Pinterest 账号中创建一个。', + 'boards_truncated' => '部分图板未能加载。如果没有看到你的图板,请打开 Pinterest 后重试。', 'search_board' => '搜索图板…', 'no_board_found' => '没有与搜索匹配的图板。', 'board_required' => '请选择一个 Pinterest 图板以发布此帖子。', @@ -464,7 +465,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => '最长 90 秒的短视频', + 'description' => '最长 15 分钟的短视频', ], 'instagram_story' => [ 'label' => '快拍', diff --git a/resources/js/app.ts b/resources/js/app.ts index de757e698..6bdb68d69 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -9,6 +9,7 @@ import { createApp, h } from 'vue'; import { initializeDataLayer } from './datalayer'; import dayjs from './dayjs'; +import { syncContentTypeMediaRules } from './lib/contentTypeMediaRules'; import { capturePageview, initializePostHog, syncPostHogContext } from './posthog'; import type { Auth } from './types'; @@ -46,10 +47,12 @@ createInertiaApp({ // re-attach the right workspace group. initializePostHog(); syncPostHogContext(props.initialPage); + syncContentTypeMediaRules(props.initialPage); capturePageview(); router.on('navigate', (event) => { syncPostHogContext(event.detail.page); + syncContentTypeMediaRules(event.detail.page); capturePageview(); }); diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index 1feb31e29..ce3d00e87 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -153,6 +153,7 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel :content-type="channel.contentType" :media="media" :boards="channel.boards ?? []" + :boards-truncated="channel.boardsTruncated ?? false" :meta="channel.meta" :disabled="disabled" :preview-only="previewOnly" diff --git a/resources/js/components/automations/config/GenerateNodeConfig.vue b/resources/js/components/automations/config/GenerateNodeConfig.vue index 1d73d91bc..0f1abd6a7 100644 --- a/resources/js/components/automations/config/GenerateNodeConfig.vue +++ b/resources/js/components/automations/config/GenerateNodeConfig.vue @@ -13,7 +13,7 @@ import { Switch } from '@/components/ui/switch'; import { useExpandedEditor } from '@/composables/useExpandedEditor'; import { getMediaRulesForContentType } from '@/composables/useMediaRules'; import { getMediaIncompatibilityReason, getPlatformMetaIssue } from '@/composables/usePostCompliance'; -import type { PinterestBoard } from '@/types'; +import type { PinterestBoard, PinterestBoardsPayload } from '@/types'; import type { Channel } from '@/types/channel'; import { ContentType } from '@/types/content-type'; import type { MediaItem } from '@/types/media'; @@ -91,8 +91,8 @@ const platformConfigs = computed>(() => { return raw ?? {}; }); -const pinterestBoards = computed>(() => { - const raw = page.props.pinterestBoards as Record | undefined; +const pinterestBoards = computed>(() => { + const raw = page.props.pinterestBoards as Record | undefined; return raw ?? {}; }); @@ -113,7 +113,7 @@ const defaultContentTypeFor = (platform: string): string => { case Platform.LinkedInPage: return ContentType.LinkedInPagePost; case Platform.TikTok: - return ContentType.TikTokVideo; + return ContentType.TikTokPhoto; case Platform.Pinterest: return ContentType.PinterestPin; case Platform.YouTube: @@ -209,7 +209,10 @@ const getCreatorInfo = (account: SocialAccount): TikTokCreatorInfo | null => tiktokCreatorInfos.value[account.id] ?? null; const getBoards = (account: SocialAccount): PinterestBoard[] => - pinterestBoards.value[account.id] ?? []; + pinterestBoards.value[account.id]?.boards ?? []; + +const boardsTruncated = (account: SocialAccount): boolean => + pinterestBoards.value[account.id]?.truncated ?? false; // Image-capability is derived from the SAME media rules the post editor uses // (per content type), never a hardcoded list — facebook_post, tiktok_photo, @@ -229,11 +232,32 @@ const imageCountCap = computed(() => ), ); -// Single picker: 0 = no image (text-only), 1 = single image, 2+ = carousel. -const imageCountOptions = computed(() => - Array.from({ length: imageCountCap.value + 1 }, (_, i) => i), +// Floor at requiresMedia (1) or content-type minFiles (e.g. Pinterest carousel = 2) +// — otherwise the empty-accounts clamp to 0 sticks after selecting them and +// surfaces a false "add an image" compliance error / under-min carousel. +const minImageCount = computed(() => + local.value.accounts.reduce((floor, a) => { + const rules = getMediaRulesForContentType(a.content_type); + let accountMin = 0; + if (rules.requiresMedia) { + accountMin = Math.max(accountMin, 1); + } + if (rules.minFiles) { + accountMin = Math.max(accountMin, rules.minFiles); + } + return Math.max(floor, accountMin); + }, 0), ); +// Single picker: 0 = no image (text-only), 1 = single image, 2+ = carousel. +// Option 0 is omitted when a media-required account is selected. +const imageCountOptions = computed(() => { + const min = Math.min(minImageCount.value, imageCountCap.value); + const max = imageCountCap.value; + + return Array.from({ length: Math.max(0, max - min + 1) }, (_, i) => i + min); +}); + const intendedImageCount = computed(() => local.value.target_slide_count); const syntheticImages = (count: number): MediaItem[] => @@ -251,14 +275,23 @@ const accountIssue = (accountId: string): string | null => { return account ? getPlatformMetaIssue(account.platform, entry.meta) : null; }; -// Clamp the chosen count to what the selected accounts actually allow — runs on -// mount too so legacy/over-cap values (or text-only accounts → 0) self-correct. +// Clamp the chosen count into [min, cap] for the current selection. Skip while +// no accounts are selected so the default of 1 is preserved until the user +// picks a destination (avoids clamp-to-0 → select Pinterest → stuck at 0). watch( - imageCountCap, - (cap) => { + [imageCountCap, minImageCount, () => local.value.accounts.length], + ([cap, min, accountCount]) => { + if (accountCount === 0) { + return; + } + if (local.value.target_slide_count > cap) { local.value.target_slide_count = cap; } + + if (local.value.target_slide_count < min) { + local.value.target_slide_count = Math.min(min, cap); + } }, { immediate: true }, ); @@ -279,6 +312,7 @@ const channels = computed(() => publishConfig: getPublishConfig(account), creatorInfo: getCreatorInfo(account), boards: getBoards(account), + boardsTruncated: boardsTruncated(account), }; }), ); diff --git a/resources/js/components/posts/editor/FacebookSettings.vue b/resources/js/components/posts/editor/FacebookSettings.vue index af379ebbc..65d5ef215 100644 --- a/resources/js/components/posts/editor/FacebookSettings.vue +++ b/resources/js/components/posts/editor/FacebookSettings.vue @@ -1,10 +1,11 @@ diff --git a/resources/js/components/posts/editor/TikTokSettings.vue b/resources/js/components/posts/editor/TikTokSettings.vue index 2c794796c..68945b87b 100644 --- a/resources/js/components/posts/editor/TikTokSettings.vue +++ b/resources/js/components/posts/editor/TikTokSettings.vue @@ -16,6 +16,7 @@ import { SelectValue, } from '@/components/ui/select'; import { getPlatformLogo } from '@/composables/usePlatformLogo'; +import { fallbackImageCapableVariant, filterImageCapableVariants } from '@/lib/aiGenerateVariants'; import { ContentType } from '@/types/content-type'; interface SocialAccount { @@ -62,11 +63,24 @@ const emit = defineEmits<{ 'update:contentType': [value: string]; }>(); -const variants = [ +const allVariants = [ { value: ContentType.TikTokVideo, labelKey: 'posts.form.tiktok.variant.video' }, { value: ContentType.TikTokPhoto, labelKey: 'posts.form.tiktok.variant.photo' }, ] as const; +const variants = computed(() => filterImageCapableVariants(allVariants, props.previewOnly)); + +watch( + () => [props.previewOnly, props.contentType, variants.value] as const, + () => { + const fallback = fallbackImageCapableVariant(props.contentType, variants.value); + if (fallback) { + emit('update:contentType', fallback); + } + }, + { immediate: true }, +); + const pickVariant = (value: string) => { if (props.disabled) return; emit('update:contentType', value); diff --git a/resources/js/composables/useMediaRules.ts b/resources/js/composables/useMediaRules.ts index e8782d1e4..8f63d69c7 100644 --- a/resources/js/composables/useMediaRules.ts +++ b/resources/js/composables/useMediaRules.ts @@ -1,160 +1,39 @@ import { computed, type Ref, type ComputedRef } from 'vue'; +import { + mediaRuleFor, + toMediaRules, + type MediaRules, +} from '@/lib/contentTypeMediaRules'; import { fromMimeType, MediaType } from '@/lib/mediaType'; -export interface MediaRules { - maxFiles: number; - minFiles?: number; - acceptImages: boolean; - acceptVideos: boolean; - acceptDocuments?: boolean; - requiresMedia: boolean; - acceptsGif: boolean; - forbidsMixedMedia?: boolean; - maxImageBytes?: number; - maxVideoBytes?: number; - maxDocumentBytes?: number; - maxVideoDurationSec?: number; - aspectRatioMin?: number; - aspectRatioMax?: number; - autoFitsImage?: boolean; -} - -const MB = 1024 * 1024; -const GB = 1024 * MB; - -const CONTENT_TYPE_RULES: Record = { - // Instagram - instagram_feed: { - maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, - aspectRatioMin: 0.8, aspectRatioMax: 1.91, - }, - instagram_reel: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 1 * GB, maxVideoDurationSec: 15 * 60, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, - }, - instagram_story: { - maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, autoFitsImage: true, - }, - - // Facebook - facebook_post: { - maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: false, - maxImageBytes: 4 * MB, maxVideoBytes: 10 * GB, maxVideoDurationSec: 240 * 60, - }, - facebook_reel: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 1 * GB, maxVideoDurationSec: 90, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, - }, - facebook_story: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 1 * GB, maxVideoDurationSec: 60, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, - }, - - // LinkedIn — one type per account kind; the publish format (single image, - // multi-image, video, or PDF document) is inferred from the attached media. - // Images (up to 10) XOR one video XOR one PDF, never mixed. - linkedin_post: { - maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true, - requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true, - maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB, - }, - linkedin_page_post: { - maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true, - requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true, - maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB, - }, - - // TikTok - tiktok_video: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - 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: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 256 * GB, maxVideoDurationSec: 3 * 60, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, - }, - - // Pinterest - pinterest_pin: { - maxFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 20 * MB, - }, - pinterest_video_pin: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 2 * GB, maxVideoDurationSec: 15 * 60, - }, - pinterest_carousel: { - maxFiles: 5, minFiles: 2, acceptImages: true, acceptVideos: false, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 20 * MB, - }, - - // X (Twitter) — accepts GIF with animation - x_post: { - maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: true, - maxImageBytes: 5 * MB, maxVideoBytes: 512 * MB, maxVideoDurationSec: 140, - }, - - // Threads - threads_post: { - maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, maxVideoDurationSec: 5 * 60, - }, - - // Bluesky — accepts GIF; tight image size (auto-resized by backend). - // The embed is images XOR video, so the two can't be combined in one post. - bluesky_post: { - maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: true, forbidsMixedMedia: true, - maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, - }, - - // Mastodon — accepts GIF - mastodon_post: { - maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: true, - maxImageBytes: 10 * MB, maxVideoBytes: 40 * MB, - }, -}; +export type { MediaRules }; +/** + * Fallback only when Inertia once-props have not synced yet (or unknown type). + * Real limits live in App\Enums\PostPlatform\ContentType::mediaRules(). + * Keep this fail-closed (no GIF) — most content types reject animated GIFs. + */ const DEFAULT_RULES: MediaRules = { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: true, + acceptsGif: false, }; -export function useMediaRules(contentType: Ref | ComputedRef) { - const rules = computed(() => { - return CONTENT_TYPE_RULES[contentType.value] || DEFAULT_RULES; - }); +export const getMediaRulesForContentType = (contentType: string): MediaRules => { + const shared = mediaRuleFor(contentType); + + if (!shared) { + return DEFAULT_RULES; + } + + return toMediaRules(shared); +}; + +export const useMediaRules = (contentType: Ref | ComputedRef) => { + const rules = computed(() => getMediaRulesForContentType(contentType.value)); const acceptMimeTypes = computed(() => { const types: string[] = []; @@ -214,8 +93,4 @@ export function useMediaRules(contentType: Ref | ComputedRef) { isValidFileType, getAcceptDescription, }; -} - -export function getMediaRulesForContentType(contentType: string): MediaRules { - return CONTENT_TYPE_RULES[contentType] || DEFAULT_RULES; -} +}; diff --git a/resources/js/lib/aiGenerateVariants.ts b/resources/js/lib/aiGenerateVariants.ts new file mode 100644 index 000000000..98e856ed6 --- /dev/null +++ b/resources/js/lib/aiGenerateVariants.ts @@ -0,0 +1,32 @@ +import { getMediaRulesForContentType } from '@/composables/useMediaRules'; + +/** + * The automation Generate node (and AI wizard) only produce images. When + * `imageOnly` is true (previewOnly / generate context), drop video-only + * content types so they can't be selected. + */ +export const filterImageCapableVariants = ( + variants: readonly T[], + imageOnly: boolean, +): T[] => { + if (! imageOnly) { + return [...variants]; + } + + return variants.filter((variant) => getMediaRulesForContentType(variant.value).acceptImages); +}; + +/** + * When the current content type was filtered out (e.g. Video Pin in Generate), + * return the first remaining variant to switch to — or null when already valid. + */ +export const fallbackImageCapableVariant = ( + contentType: string, + available: readonly { value: string }[], +): string | null => { + if (available.some((variant) => variant.value === contentType)) { + return null; + } + + return available[0]?.value ?? null; +}; diff --git a/resources/js/lib/contentTypeMediaRules.ts b/resources/js/lib/contentTypeMediaRules.ts new file mode 100644 index 000000000..dd771f660 --- /dev/null +++ b/resources/js/lib/contentTypeMediaRules.ts @@ -0,0 +1,109 @@ +import type { Page } from '@inertiajs/core'; + +/** CamelCase shape consumed by the Vue media picker / compliance checks. */ +export type MediaRules = { + maxFiles: number; + minFiles?: number; + acceptImages: boolean; + acceptVideos: boolean; + acceptDocuments?: boolean; + requiresMedia: boolean; + acceptsGif: boolean; + forbidsMixedMedia?: boolean; + maxImageBytes?: number; + maxVideoBytes?: number; + maxDocumentBytes?: number; + maxVideoDurationSec?: number; + aspectRatioMin?: number; + aspectRatioMax?: number; + autoFitsImage?: boolean; +}; + +/** + * Snake_case payload from ContentType::mediaRules() / mediaRulesForFrontend(). + */ +export type ContentTypeMediaRule = { + max_files: number; + min_files: number | null; + accept_images: boolean; + accept_videos: boolean; + accept_documents: boolean; + requires_media: boolean; + accepts_gif: boolean; + forbids_mixed_media: boolean; + max_image_bytes: number | null; + max_video_bytes: number | null; + max_document_bytes: number | null; + max_video_duration_sec: number | null; + aspect_ratio_min: number | null; + aspect_ratio_max: number | null; + auto_fits_image: boolean; +}; + +type ContentTypeMediaRulesMap = Record; + +let cachedRules: ContentTypeMediaRulesMap | null = null; + +export const syncContentTypeMediaRules = (page: Page): void => { + const rules = page.props.contentTypeMediaRules as ContentTypeMediaRulesMap | undefined; + + if (rules) { + cachedRules = rules; + } +}; + +export const mediaRuleFor = (contentType: string): ContentTypeMediaRule | undefined => { + return cachedRules?.[contentType]; +}; + +export const toMediaRules = (rule: ContentTypeMediaRule): MediaRules => { + const mapped: MediaRules = { + maxFiles: rule.max_files, + acceptImages: rule.accept_images, + acceptVideos: rule.accept_videos, + requiresMedia: rule.requires_media, + acceptsGif: rule.accepts_gif, + }; + + if (rule.min_files !== null) { + mapped.minFiles = rule.min_files; + } + + if (rule.accept_documents) { + mapped.acceptDocuments = true; + } + + if (rule.forbids_mixed_media) { + mapped.forbidsMixedMedia = true; + } + + if (rule.max_image_bytes !== null) { + mapped.maxImageBytes = rule.max_image_bytes; + } + + if (rule.max_video_bytes !== null) { + mapped.maxVideoBytes = rule.max_video_bytes; + } + + if (rule.max_document_bytes !== null) { + mapped.maxDocumentBytes = rule.max_document_bytes; + } + + if (rule.max_video_duration_sec !== null) { + mapped.maxVideoDurationSec = rule.max_video_duration_sec; + } + + if (rule.aspect_ratio_min !== null) { + mapped.aspectRatioMin = rule.aspect_ratio_min; + } + + if (rule.aspect_ratio_max !== null) { + mapped.aspectRatioMax = rule.aspect_ratio_max; + } + + if (rule.auto_fits_image) { + mapped.autoFitsImage = true; + } + + return mapped; +}; diff --git a/resources/js/pages/posts/Edit.vue b/resources/js/pages/posts/Edit.vue index 2b6e2f3ca..667a43f01 100644 --- a/resources/js/pages/posts/Edit.vue +++ b/resources/js/pages/posts/Edit.vue @@ -25,7 +25,7 @@ import dayjs from '@/dayjs'; import debounce from '@/debounce'; import AppLayout from '@/layouts/AppLayout.vue'; import { destroy as destroyPost, update as updatePost } from '@/routes/app/posts'; -import type { PinterestBoard } from '@/types'; +import type { PinterestBoardsPayload } from '@/types'; import type { MediaItem } from '@/types/media'; import { PostStatus } from '@/types/post'; @@ -86,7 +86,7 @@ const props = defineProps<{ post: Post; socialAccounts: SocialAccount[]; platformConfigs: Record; - pinterestBoards: Record; + pinterestBoards: Record; tiktokCreatorInfos?: Record | null; labels: { id: string; name: string; color: string }[]; signatures: { id: string; name: string; content: string }[]; diff --git a/resources/js/ssr.ts b/resources/js/ssr.ts index b57a46385..0c43b75eb 100644 --- a/resources/js/ssr.ts +++ b/resources/js/ssr.ts @@ -4,6 +4,8 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { createSSRApp, DefineComponent, h } from 'vue'; import { renderToString } from 'vue/server-renderer'; +import { syncContentTypeMediaRules } from './lib/contentTypeMediaRules'; + const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; createServer( @@ -17,8 +19,11 @@ createServer( `./pages/${name}.vue`, import.meta.glob('./pages/**/*.vue'), ), - setup: ({ App, props, plugin }) => - createSSRApp({ render: () => h(App, props) }).use(plugin), + setup: ({ App, props, plugin }) => { + syncContentTypeMediaRules(props.initialPage); + + return createSSRApp({ render: () => h(App, props) }).use(plugin); + }, }), { cluster: true }, ); diff --git a/resources/js/types/channel.ts b/resources/js/types/channel.ts index d2c58ebfa..81565fff7 100644 --- a/resources/js/types/channel.ts +++ b/resources/js/types/channel.ts @@ -40,4 +40,5 @@ export interface Channel { publishConfig?: Record | null; creatorInfo?: ChannelTikTokCreatorInfo | null; boards?: PinterestBoard[]; + boardsTruncated?: boolean; } diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index e95a50378..897ce048a 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -62,12 +62,31 @@ export interface NavItem { badge?: string; } +export interface ContentTypeMediaRule { + max_files: number; + min_files: number | null; + accept_images: boolean; + accept_videos: boolean; + accept_documents: boolean; + requires_media: boolean; + accepts_gif: boolean; + forbids_mixed_media: boolean; + max_image_bytes: number | null; + max_video_bytes: number | null; + max_document_bytes: number | null; + max_video_duration_sec: number | null; + aspect_ratio_min: number | null; + aspect_ratio_max: number | null; + auto_fits_image: boolean; +} + export interface SharedData { name: string; auth: Auth; flash: FlashData; sidebarOpen: boolean; selfHosted: boolean; + contentTypeMediaRules?: Record; [key: string]: unknown; } @@ -97,6 +116,12 @@ export interface PinterestBoard { name: string; } +/** Per-account payload from ListPinterestBoards (Inertia + API/MCP). */ +export interface PinterestBoardsPayload { + boards: PinterestBoard[]; + truncated: boolean; +} + export interface ContentLanguageOption { value: string; label: string; diff --git a/routes/api.php b/routes/api.php index fe3c48f18..0ed1f0b72 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,8 +13,8 @@ use Illuminate\Support\Facades\Route; Route::post('/uploads/{token}', [UploadController::class, 'store']) - ->middleware(['signed', 'throttle:10,1']) - ->where('token', '[0-9a-f-]{36}') + ->middleware(['signed', 'throttle:signed-uploads']) + ->whereUuid('token') ->name('api.uploads.store'); Route::middleware(['auth:api', 'workspace.token', 'throttle:api'])->group(function () { @@ -50,6 +50,12 @@ // Social Accounts Route::get('/social-accounts', [SocialAccountController::class, 'index'])->name('api.social-accounts.index'); Route::put('/social-accounts/{account}/toggle', [SocialAccountController::class, 'toggle'])->name('api.social-accounts.toggle'); + Route::get('/social-accounts/{account}/boards', [SocialAccountController::class, 'boards']) + ->middleware('throttle:60,1') + ->name('api.social-accounts.boards'); + Route::get('/social-accounts/{account}/channels', [SocialAccountController::class, 'channels']) + ->middleware('throttle:60,1') + ->name('api.social-accounts.channels'); // API Keys Route::get('/api-keys', [ApiKeyController::class, 'index'])->name('api.api-keys.index'); diff --git a/tests/Feature/Api/DiscordChannelsApiTest.php b/tests/Feature/Api/DiscordChannelsApiTest.php new file mode 100644 index 000000000..adefb1fd4 --- /dev/null +++ b/tests/Feature/Api/DiscordChannelsApiTest.php @@ -0,0 +1,94 @@ + 'BOTTOKEN', + 'services.discord.client_id' => '999000111', + ]); + + $result = createApiTestToken(); + $this->user = $result['user']; + $this->workspace = $result['workspace']; + $this->plainToken = $result['plain_token']; +}); + +it('lists discord channels for a connected account', function () { + $account = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => '111222333', + ]); + + Http::fake([ + config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response([ + ['id' => '1', 'name' => 'general', 'type' => 0], + ['id' => '2', 'name' => 'voice', 'type' => 2], + ['id' => '3', 'name' => 'news', 'type' => 5], + ], 200), + config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response([ + ['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072'], + ], 200), + config('trypost.platforms.discord.api').'/guilds/111222333/members/999000111' => Http::response(['roles' => []], 200), + ]); + + $response = $this->getJson(route('api.social-accounts.channels', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]); + + $response->assertOk(); + $response->assertExactJson([ + 'channels' => [ + ['id' => '1', 'name' => 'general'], + ['id' => '3', 'name' => 'news'], + ], + ]); +}); + +it('returns bad gateway when discord channel lookup fails', function () { + $account = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => '111222333', + ]); + + Http::fake([ + config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response('upstream down', 500), + ]); + + $this->getJson(route('api.social-accounts.channels', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]) + ->assertStatus(502) + ->assertJsonPath('message', 'Discord channel lookup failed (500).'); +}); + +it('rejects channels listing for non-discord accounts', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + ]); + + $this->getJson(route('api.social-accounts.channels', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]) + ->assertUnprocessable() + ->assertJsonPath('message', 'Channels are only available for Discord accounts.'); +}); + +it('cannot list channels for an account from another workspace', function () { + $otherWorkspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $this->getJson(route('api.social-accounts.channels', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ])->assertNotFound(); +}); diff --git a/tests/Feature/Api/PinterestBoardsApiTest.php b/tests/Feature/Api/PinterestBoardsApiTest.php new file mode 100644 index 000000000..db7fbf706 --- /dev/null +++ b/tests/Feature/Api/PinterestBoardsApiTest.php @@ -0,0 +1,137 @@ +user = $result['user']; + $this->workspace = $result['workspace']; + $this->plainToken = $result['plain_token']; +}); + +it('lists pinterest boards for a connected account', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'pinterest-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'items' => [ + ['id' => 'board_1', 'name' => 'Ideas', 'privacy' => 'PUBLIC'], + ['id' => 'board_2', 'name' => 'Product', 'privacy' => 'PUBLIC'], + ], + ], 200), + ]); + + $response = $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]); + + $response->assertOk(); + $response->assertExactJson([ + 'boards' => [ + ['id' => 'board_1', 'name' => 'Ideas'], + ['id' => 'board_2', 'name' => 'Product'], + ], + 'truncated' => false, + ]); +}); + +it('returns an empty boards list when pinterest has none', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'pinterest-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'items' => [], + ], 200), + ]); + + $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]) + ->assertOk() + ->assertExactJson(['boards' => [], 'truncated' => false]); +}); + +it('returns unauthorized when the pinterest token is expired', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'expired-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'message' => 'Access token has expired or been revoked', + ], 401), + ]); + + $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]) + ->assertUnauthorized() + ->assertJsonPath('message', 'Access token has expired or been revoked'); +}); + +it('returns bad gateway when pinterest is unavailable', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'pinterest-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'message' => 'Internal error', + ], 500), + ]); + + $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]) + ->assertStatus(502) + ->assertJsonPath('message', 'Pinterest server error. Please try again.'); +}); + +it('rejects boards listing for non-pinterest accounts', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + ]); + + $response = $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]); + + $response->assertUnprocessable(); + $response->assertJsonPath('message', 'Boards are only available for Pinterest accounts.'); +}); + +it('cannot list boards for an account from another workspace', function () { + $otherWorkspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $response = $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]); + + $response->assertNotFound(); +}); + +it('requires authentication to list boards', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->getJson(route('api.social-accounts.boards', $account)) + ->assertUnauthorized(); +}); diff --git a/tests/Feature/Api/PlatformApiTest.php b/tests/Feature/Api/PlatformApiTest.php index 204253c6e..16d43b50b 100644 --- a/tests/Feature/Api/PlatformApiTest.php +++ b/tests/Feature/Api/PlatformApiTest.php @@ -10,7 +10,7 @@ }); it('lists content types per platform', function () { - $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) ->getJson(route('api.content-types')) ->assertOk() ->assertJsonStructure([ @@ -23,11 +23,39 @@ 'allowed_media_types', 'default_content_type', 'content_types' => [ - '*' => ['value', 'label', 'description', 'max_media_count', 'requires_media'], + '*' => [ + 'value', + 'label', + 'description', + 'max_media_count', + 'min_media_count', + 'requires_media', + 'accept_images', + 'accept_videos', + 'accept_documents', + 'accepts_gif', + 'forbids_mixed_media', + 'max_video_duration_sec', + 'max_image_bytes', + 'max_video_bytes', + 'max_document_bytes', + ], ], ], ], ]); + + $platforms = collect($response->json('platforms')); + $instagramTypes = collect($platforms->firstWhere('platform', 'instagram')['content_types']); + $facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']); + $pinterestTypes = collect($platforms->firstWhere('platform', 'pinterest')['content_types']); + + expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900); + expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_bytes'])->toBe(1 * 1024 * 1024 * 1024); + expect($instagramTypes->firstWhere('value', 'instagram_reel')['accept_images'])->toBeFalse(); + expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90); + expect($pinterestTypes->firstWhere('value', 'pinterest_carousel')['min_media_count'])->toBe(2); + expect($pinterestTypes->firstWhere('value', 'pinterest_pin')['min_media_count'])->toBe(0); }); it('rejects content-types without auth', function () { diff --git a/tests/Feature/Api/UploadControllerTest.php b/tests/Feature/Api/UploadControllerTest.php index ac69900e9..dc8e66e4f 100644 --- a/tests/Feature/Api/UploadControllerTest.php +++ b/tests/Feature/Api/UploadControllerTest.php @@ -8,6 +8,7 @@ use App\Models\Workspace; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\URL; use Illuminate\Support\Str; @@ -102,9 +103,26 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = expect(Media::where('upload_token', $token)->count())->toBe(1); }); -test('rejects file larger than 50MB', function () { +test('rejects file larger than the per-type media cap', function () { + config(['trypost.media.max_size_mb.video' => 1]); + + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->create('huge.mp4', 1024 + 1, 'video/mp4'); + + $response = $this->postJson(signedUploadUrl($this->workspace, $token), ['media' => $file]); + + $response->assertStatus(422); + expect(Media::where('upload_token', $token)->exists())->toBeFalse(); +}); + +test('rejects an image larger than the image media cap even when under the video ceiling', function () { + config([ + 'trypost.media.max_size_mb.image' => 1, + 'trypost.media.max_size_mb.video' => 1024, + ]); + $token = (string) Str::uuid(); - $file = UploadedFile::fake()->create('huge.mp4', 51 * 1024 + 1, 'video/mp4'); + $file = UploadedFile::fake()->create('big.jpg', 1024 + 1, 'image/jpeg'); $response = $this->postJson(signedUploadUrl($this->workspace, $token), ['media' => $file]); @@ -112,6 +130,52 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = expect(Media::where('upload_token', $token)->exists())->toBeFalse(); }); +test('rejects a document larger than the document media cap even when under the video ceiling', function () { + config([ + 'trypost.media.max_size_mb.document' => 1, + 'trypost.media.max_size_mb.video' => 1024, + ]); + + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->create('big.pdf', 1024 + 1, 'application/pdf'); + + $response = $this->postJson(signedUploadUrl($this->workspace, $token), ['media' => $file]); + + $response->assertStatus(422); + expect(Media::where('upload_token', $token)->exists())->toBeFalse(); +}); + +test('stores a video upload via the streaming path', function () { + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->create('clip.mp4', 256, 'video/mp4'); + + $this->post(signedUploadUrl($this->workspace, $token), [ + 'media' => $file, + ])->assertCreated(); + + $media = Media::where('upload_token', $token)->first(); + expect($media)->not->toBeNull() + ->and($media->type->value)->toBe('video') + ->and(Storage::exists($media->path))->toBeTrue(); +}); + +test('releases the upload token when media persistence fails', function () { + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->create('clip.mp4', 256, 'video/mp4'); + $cacheKey = "media:signed-upload:{$token}"; + + DB::shouldReceive('transaction') + ->once() + ->andThrow(new RuntimeException('disk unavailable')); + + $this->postJson(signedUploadUrl($this->workspace, $token), [ + 'media' => $file, + ])->assertServerError(); + + expect(Cache::has($cacheKey))->toBeFalse() + ->and(Media::where('upload_token', $token)->exists())->toBeFalse(); +}); + test('rejects disallowed mime type', function () { $token = (string) Str::uuid(); $file = UploadedFile::fake()->create('evil.exe', 10, 'application/octet-stream'); @@ -122,18 +186,37 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = expect(Media::where('upload_token', $token)->exists())->toBeFalse(); }); -test('rate limits floods from the same IP', function () { - for ($i = 0; $i < 10; $i++) { +test('rate limits floods from the same workspace', function () { + for ($i = 0; $i < 60; $i++) { $this->postJson( signedUploadUrl($this->workspace, (string) Str::uuid()), ['media' => UploadedFile::fake()->image("f{$i}.png", 16, 16)], - ); + )->assertSuccessful(); } - $response = $this->postJson( + $this->postJson( signedUploadUrl($this->workspace, (string) Str::uuid()), ['media' => UploadedFile::fake()->image('over.png', 16, 16)], - ); + )->assertStatus(429); +}); + +test('different workspaces on the same IP do not share the upload rate limit', function () { + $otherWorkspace = Workspace::factory()->create(); + + for ($i = 0; $i < 60; $i++) { + $this->postJson( + signedUploadUrl($this->workspace, (string) Str::uuid()), + ['media' => UploadedFile::fake()->image("a{$i}.png", 16, 16)], + )->assertSuccessful(); + } + + $this->postJson( + signedUploadUrl($this->workspace, (string) Str::uuid()), + ['media' => UploadedFile::fake()->image('blocked.png', 16, 16)], + )->assertStatus(429); - $response->assertStatus(429); + $this->postJson( + signedUploadUrl($otherWorkspace, (string) Str::uuid()), + ['media' => UploadedFile::fake()->image('other.png', 16, 16)], + )->assertSuccessful(); }); diff --git a/tests/Feature/Automation/Automation/ReadActionsTest.php b/tests/Feature/Automation/Automation/ReadActionsTest.php index dd9675057..d4d778561 100644 --- a/tests/Feature/Automation/Automation/ReadActionsTest.php +++ b/tests/Feature/Automation/Automation/ReadActionsTest.php @@ -135,7 +135,10 @@ }); it('maps pinterest boards for pinterest accounts', function () { - $this->mock(PinterestPublisher::class, fn ($mock) => $mock->shouldReceive('getBoards')->andReturn([['id' => 'b1']])); + $this->mock(PinterestPublisher::class, fn ($mock) => $mock->shouldReceive('getBoards')->andReturn([ + 'boards' => [['id' => 'b1', 'name' => 'Ideas']], + 'truncated' => true, + ])); $this->mock(TikTokCreatorInfo::class); $workspace = Workspace::factory()->create(); @@ -144,5 +147,8 @@ $result = app(GetAutomationEditorData::class)($automation); - expect($result['pinterestBoards']->get($pinterest->id))->toBe([['id' => 'b1']]); + expect($result['pinterestBoards']->get($pinterest->id))->toBe([ + 'boards' => [['id' => 'b1', 'name' => 'Ideas']], + 'truncated' => true, + ]); }); diff --git a/tests/Feature/Automation/GenerateNodeValidationTest.php b/tests/Feature/Automation/GenerateNodeValidationTest.php index 097662c6a..295bee900 100644 --- a/tests/Feature/Automation/GenerateNodeValidationTest.php +++ b/tests/Feature/Automation/GenerateNodeValidationTest.php @@ -37,6 +37,25 @@ ->assertJsonValidationErrors(['nodes.1.data.accounts']); }); +it('rejects a generate node that targets Pinterest carousel below the minimum slide count', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->putJson(route('app.automations.update', $automation->id), [ + 'nodes' => [ + ['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']], + ['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [ + 'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_carousel', 'meta' => ['board_id' => 'board-1']]], + 'prompt_template' => 'hi', + 'target_slide_count' => 1, + ]], + ], + 'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['nodes.1.data.accounts']); +}); + it('allows saving a generate node within the content type limit', function () { $automation = Automation::factory()->for($this->workspace)->create(); @@ -58,6 +77,66 @@ expect($automation->fresh()->nodes)->toHaveCount(2); }); +it('rejects a generate node that targets Pinterest with zero images', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->putJson(route('app.automations.update', $automation->id), [ + 'nodes' => [ + ['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']], + ['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [ + 'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_pin', 'meta' => ['board_id' => 'board-1']]], + 'prompt_template' => 'hi', + 'target_slide_count' => 0, + ]], + ], + 'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['nodes.1.data.accounts']); +}); + +it('rejects a generate node that targets a video-only Pinterest format', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->putJson(route('app.automations.update', $automation->id), [ + 'nodes' => [ + ['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']], + ['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [ + 'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_video_pin', 'meta' => ['board_id' => 'board-1']]], + 'prompt_template' => 'hi', + 'target_slide_count' => 1, + ]], + ], + 'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['nodes.1.data.accounts']); +}); + +it('allows saving a Pinterest pin generate node with one image', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [ + ['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']], + ['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [ + 'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_pin', 'meta' => ['board_id' => 'board-1']]], + 'prompt_template' => 'hi', + 'target_slide_count' => 1, + 'style' => 'tweet_card', + ]], + ], + 'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']], + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + expect($automation->fresh()->nodes)->toHaveCount(2); +}); + it('persists the brand toggles on the generate node', function () { $automation = Automation::factory()->for($this->workspace)->create(); diff --git a/tests/Feature/ContentTypeMediaRulesShareTest.php b/tests/Feature/ContentTypeMediaRulesShareTest.php new file mode 100644 index 000000000..db38a8fa3 --- /dev/null +++ b/tests/Feature/ContentTypeMediaRulesShareTest.php @@ -0,0 +1,32 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +test('inertia shares content type media rules for the frontend', function () { + $this->actingAs($this->user) + ->get(route('app.posts.index')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('contentTypeMediaRules') + ->where('contentTypeMediaRules.instagram_reel.max_video_duration_sec', 900) + ->where('contentTypeMediaRules.instagram_reel.max_video_bytes', 1 * 1024 * 1024 * 1024) + ->where('contentTypeMediaRules.instagram_reel.accept_images', false) + ->where('contentTypeMediaRules.instagram_feed.requires_media', true) + ->where('contentTypeMediaRules.discord_message.accepts_gif', true) + ->where('contentTypeMediaRules.facebook_reel.max_video_duration_sec', 90) + ->where('contentTypeMediaRules.tiktok_video.max_video_duration_sec', null) + ->where('contentTypeMediaRules.linkedin_post.max_document_bytes', 100 * 1024 * 1024) + ); +}); diff --git a/tests/Feature/Mcp/ListDiscordChannelsToolTest.php b/tests/Feature/Mcp/ListDiscordChannelsToolTest.php new file mode 100644 index 000000000..c4c6d4241 --- /dev/null +++ b/tests/Feature/Mcp/ListDiscordChannelsToolTest.php @@ -0,0 +1,95 @@ + 'BOTTOKEN', + 'services.discord.client_id' => '999000111', + ]); + + $this->user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +test('lists discord channels as id and name', function () { + $account = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => '111222333', + ]); + + Http::fake([ + config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response([ + ['id' => '1', 'name' => 'general', 'type' => 0], + ['id' => '2', 'name' => 'voice', 'type' => 2], + ], 200), + config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response([ + ['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072'], + ], 200), + config('trypost.platforms.discord.api').'/guilds/111222333/members/999000111' => Http::response(['roles' => []], 200), + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListDiscordChannelsTool::class, ['account_id' => $account->id]); + + $response->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->has('channels', 1) + ->where('channels.0.id', '1') + ->where('channels.0.name', 'general'); + }); +}); + +test('returns an actionable error when discord is unavailable', function () { + $account = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => '111222333', + ]); + + Http::fake([ + config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response('upstream down', 500), + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListDiscordChannelsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['Discord channel lookup failed (500).']); +}); + +test('rejects non-discord accounts', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListDiscordChannelsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['This tool only works with Discord social accounts.']); +}); + +test('cannot list channels for another workspace account', function () { + $otherWorkspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListDiscordChannelsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['Social account not found.']); +}); diff --git a/tests/Feature/Mcp/ListPinterestBoardsToolTest.php b/tests/Feature/Mcp/ListPinterestBoardsToolTest.php new file mode 100644 index 000000000..7f7575e7f --- /dev/null +++ b/tests/Feature/Mcp/ListPinterestBoardsToolTest.php @@ -0,0 +1,98 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +test('lists pinterest boards as id and name', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'pinterest-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'items' => [ + ['id' => 'board_1', 'name' => 'Ideas', 'privacy' => 'PUBLIC'], + ['id' => 'board_2', 'name' => 'Product'], + ], + ], 200), + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]); + + $response->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->has('boards', 2) + ->where('boards.0.id', 'board_1') + ->where('boards.0.name', 'Ideas') + ->where('boards.1.id', 'board_2') + ->where('boards.1.name', 'Product') + ->where('truncated', false); + }); +}); + +test('returns an actionable error when pinterest token is expired', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'expired-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'message' => 'Access token has expired or been revoked', + ], 401), + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['Access token has expired or been revoked']); +}); + +test('rejects non-pinterest accounts', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['This tool only works with Pinterest social accounts.']); +}); + +test('cannot list boards for another workspace account', function () { + $otherWorkspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['Social account not found.']); +}); + +test('validates account_id is required', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, []); + + $response->assertHasErrors(); +}); diff --git a/tests/Feature/Mcp/PlatformToolTest.php b/tests/Feature/Mcp/PlatformToolTest.php index e603f7cf2..d5f3a7951 100644 --- a/tests/Feature/Mcp/PlatformToolTest.php +++ b/tests/Feature/Mcp/PlatformToolTest.php @@ -33,6 +33,27 @@ 'default_content_type', 'content_types', ]) + ->has('content_types', fn (AssertableJson $types) => $types + ->each(fn (AssertableJson $type) => $type + ->hasAll([ + 'value', + 'label', + 'description', + 'max_media_count', + 'min_media_count', + 'requires_media', + 'accept_images', + 'accept_videos', + 'accept_documents', + 'accepts_gif', + 'forbids_mixed_media', + 'max_video_duration_sec', + 'max_image_bytes', + 'max_video_bytes', + 'max_document_bytes', + ]) + ) + ) ) ); }); @@ -45,3 +66,23 @@ $response->assertOk() ->assertSee(['linkedin', 'linkedin_post', 'x_post', 'instagram_feed', 'mastodon_post']); }); + +test('list content types exposes reel max video durations', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(ListContentTypesTool::class, []); + + $response->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->etc(); + + $platforms = collect($json->toArray()['platforms']); + $instagramTypes = collect($platforms->firstWhere('platform', 'instagram')['content_types']); + $facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']); + $pinterestTypes = collect($platforms->firstWhere('platform', 'pinterest')['content_types']); + + expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900); + expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_bytes'])->toBe(1 * 1024 * 1024 * 1024); + expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90); + expect($pinterestTypes->firstWhere('value', 'pinterest_carousel')['min_media_count'])->toBe(2); + }); +}); diff --git a/tests/Feature/Mcp/RequestMediaUploadToolTest.php b/tests/Feature/Mcp/RequestMediaUploadToolTest.php index 8ce7afbce..e7177eb06 100644 --- a/tests/Feature/Mcp/RequestMediaUploadToolTest.php +++ b/tests/Feature/Mcp/RequestMediaUploadToolTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\Media\Type as MediaType; use App\Enums\UserWorkspace\Role; use App\Mcp\Servers\TryPostServer; use App\Mcp\Tools\Post\RequestMediaUploadTool; @@ -26,7 +27,10 @@ $json->has('upload_token') ->has('upload_url') ->has('expires_at') - ->where('max_bytes', 52428800) + ->where('max_bytes', MediaType::Video->maxSizeInBytes()) + ->where('max_bytes_by_type.image', MediaType::Image->maxSizeInBytes()) + ->where('max_bytes_by_type.video', MediaType::Video->maxSizeInBytes()) + ->where('max_bytes_by_type.document', MediaType::Document->maxSizeInBytes()) ->where('field_name', 'media') ->etc(); }); diff --git a/tests/Feature/Services/Social/PinterestPublisherTest.php b/tests/Feature/Services/Social/PinterestPublisherTest.php index c39e1ffd6..fbd9c0a95 100644 --- a/tests/Feature/Services/Social/PinterestPublisherTest.php +++ b/tests/Feature/Services/Social/PinterestPublisherTest.php @@ -512,10 +512,69 @@ ], 200), ]); - $boards = $this->publisher->getBoards($this->socialAccount); + $result = $this->publisher->getBoards($this->socialAccount); - expect($boards)->toHaveCount(2); - expect($boards[0]['id'])->toBe('board_1'); + expect($result['boards'])->toHaveCount(2) + ->and($result['boards'][0]['id'])->toBe('board_1') + ->and($result['truncated'])->toBeFalse(); +}); + +test('pinterest publisher paginates boards via bookmark', function () { + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::sequence() + ->push([ + 'items' => [ + ['id' => 'board_1', 'name' => 'Board 1'], + ], + 'bookmark' => 'page-2', + ], 200) + ->push([ + 'items' => [ + ['id' => 'board_2', 'name' => 'Board 2'], + ], + 'bookmark' => 'page-3', + ], 200) + ->push([ + 'items' => [ + ['id' => 'board_3', 'name' => 'Board 3'], + ], + ], 200), + ]); + + $result = $this->publisher->getBoards($this->socialAccount); + + expect($result['boards'])->toHaveCount(3) + ->and($result['boards'][0]['id'])->toBe('board_1') + ->and($result['boards'][1]['id'])->toBe('board_2') + ->and($result['boards'][2]['id'])->toBe('board_3') + ->and($result['truncated'])->toBeFalse(); + + Http::assertSentCount(3); +}); + +test('pinterest publisher stops board pagination on a repeated bookmark', function () { + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::sequence() + ->push([ + 'items' => [['id' => 'board_1', 'name' => 'Board 1']], + 'bookmark' => 'stuck', + ], 200) + ->push([ + 'items' => [['id' => 'board_2', 'name' => 'Board 2']], + 'bookmark' => 'stuck', + ], 200) + ->push([ + 'items' => [['id' => 'should_not_fetch', 'name' => 'Nope']], + ], 200), + ]); + + $result = $this->publisher->getBoards($this->socialAccount); + + expect($result['boards'])->toHaveCount(2) + ->and(collect($result['boards'])->pluck('id')->all())->toBe(['board_1', 'board_2']) + ->and($result['truncated'])->toBeTrue(); + + Http::assertSentCount(2); }); test('pinterest publisher can publish video pin', function () { diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index 6dd1ba83d..aa87067af 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\Media\Type as MediaType; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; @@ -21,11 +22,69 @@ test('content type has correct descriptions', function () { expect(ContentType::InstagramFeed->description())->toContain('feed'); - expect(ContentType::InstagramReel->description())->toContain('90 seconds'); + expect(ContentType::InstagramReel->description())->toContain('15 minutes'); + expect(ContentType::FacebookReel->description())->toContain('90 seconds'); expect(ContentType::InstagramStory->description())->toContain('24 hours'); expect(ContentType::YouTubeShort->description())->toContain('3 minutes'); }); +test('content type exposes max video duration in seconds', function () { + expect(ContentType::InstagramReel->maxVideoDurationSec())->toBe(15 * 60); + expect(ContentType::FacebookReel->maxVideoDurationSec())->toBe(90); + expect(ContentType::YouTubeShort->maxVideoDurationSec())->toBe(3 * 60); + expect(ContentType::TikTokVideo->maxVideoDurationSec())->toBeNull(); +}); + +test('media rules for frontend expose the full editor rule set keyed by content type', function () { + $rules = ContentType::mediaRulesForFrontend(); + + expect($rules)->toHaveCount(count(ContentType::cases())); + + expect($rules['instagram_reel'])->toMatchArray([ + 'max_files' => 1, + 'accept_images' => false, + 'accept_videos' => true, + 'requires_media' => true, + 'max_video_bytes' => 1 * 1024 * 1024 * 1024, + 'max_video_duration_sec' => 900, + 'aspect_ratio_min' => 0.5, + 'aspect_ratio_max' => 0.6, + ]); + + expect($rules['facebook_reel']['max_video_duration_sec'])->toBe(90); + expect($rules['tiktok_video']['max_video_duration_sec'])->toBeNull(); + expect($rules['linkedin_post']['max_document_bytes'])->toBe(100 * 1024 * 1024); + expect($rules['pinterest_carousel']['min_files'])->toBe(2); + expect($rules['x_post']['accepts_gif'])->toBeTrue(); + expect($rules['instagram_feed']['requires_media'])->toBeTrue(); + expect($rules['discord_message']['accepts_gif'])->toBeTrue(); + expect($rules['telegram_post']['accepts_gif'])->toBeTrue(); +}); + +test('listing array mirrors media capability fields for api and mcp', function () { + $listing = ContentType::PinterestCarousel->toListingArray(); + + expect($listing)->toMatchArray([ + 'value' => 'pinterest_carousel', + 'max_media_count' => 5, + 'min_media_count' => 2, + 'requires_media' => true, + 'accept_images' => true, + 'accept_videos' => false, + ]); + + expect(ContentType::InstagramReel->toListingArray()['accept_images'])->toBeFalse(); +}); + +test('media rules reuse enum capability helpers', function () { + $rules = ContentType::InstagramStory->mediaRules(); + + expect($rules['accept_images'])->toBe(ContentType::InstagramStory->supportsImage()) + ->and($rules['accept_videos'])->toBe(ContentType::InstagramStory->supportsVideo()) + ->and($rules['auto_fits_image'])->toBeTrue() + ->and($rules['max_files'])->toBe(ContentType::InstagramStory->maxMediaCount()); +}); + test('content type maps to correct platform', function () { expect(ContentType::InstagramFeed->platform())->toBe(Platform::Instagram); expect(ContentType::InstagramReel->platform())->toBe(Platform::Instagram); @@ -95,10 +154,10 @@ test('content type requires media correctly', function () { expect(ContentType::InstagramReel->requiresMedia())->toBeTrue(); + expect(ContentType::InstagramFeed->requiresMedia())->toBeTrue(); expect(ContentType::TikTokVideo->requiresMedia())->toBeTrue(); expect(ContentType::YouTubeShort->requiresMedia())->toBeTrue(); expect(ContentType::PinterestPin->requiresMedia())->toBeTrue(); - expect(ContentType::InstagramFeed->requiresMedia())->toBeFalse(); expect(ContentType::LinkedInPost->requiresMedia())->toBeFalse(); expect(ContentType::XPost->requiresMedia())->toBeFalse(); expect(ContentType::ThreadsPost->requiresMedia())->toBeFalse(); @@ -106,6 +165,126 @@ expect(ContentType::MastodonPost->requiresMedia())->toBeFalse(); }); +test('media rules keep editor parity for gif and requires_media flags', function () { + expect(ContentType::DiscordMessage->acceptsGif())->toBeTrue(); + expect(ContentType::TelegramPost->acceptsGif())->toBeTrue(); + expect(ContentType::InstagramFeed->mediaRules()['requires_media'])->toBeTrue(); + expect(ContentType::DiscordMessage->mediaRules()['accepts_gif'])->toBeTrue(); +}); + +/** + * Lock the flags / limits that used to live in the Vue CONTENT_TYPE_RULES map + * so a future centralization drift cannot silently flip editor behavior again. + * Byte caps are the post-clamp values (min(platform, trypost.media hard limit)). + */ +test('media rules preserve pre-centralization editor limits for mapped types', function () { + $mb = 1024 * 1024; + $gb = 1024 * $mb; + $hardImage = MediaType::Image->maxSizeInBytes(); + $hardVideo = MediaType::Video->maxSizeInBytes(); + $hardDocument = MediaType::Document->maxSizeInBytes(); + + $expected = [ + 'instagram_feed' => [ + 'requires_media' => true, + 'accepts_gif' => false, + 'max_files' => 10, + 'max_video_duration_sec' => 60, + 'max_image_bytes' => min(8 * $mb, $hardImage), + 'max_video_bytes' => min(100 * $mb, $hardVideo), + ], + 'instagram_reel' => [ + 'requires_media' => true, + 'accepts_gif' => false, + 'max_files' => 1, + 'max_video_duration_sec' => 900, + 'max_video_bytes' => min(1 * $gb, $hardVideo), + ], + 'youtube_short' => [ + 'requires_media' => true, + 'accepts_gif' => false, + 'max_files' => 1, + 'max_video_duration_sec' => 180, + // Platform advertises 256GB; editor must not exceed upload hard cap. + 'max_video_bytes' => $hardVideo, + ], + 'pinterest_pin' => [ + 'requires_media' => true, + 'accepts_gif' => false, + 'max_files' => 1, + // Platform advertises 20MB; hard image cap is typically 10MB. + 'max_image_bytes' => $hardImage, + ], + 'facebook_post' => [ + 'requires_media' => false, + 'accepts_gif' => false, + 'max_files' => 10, + 'max_video_duration_sec' => 240 * 60, + 'max_video_bytes' => $hardVideo, + ], + 'linkedin_post' => [ + 'requires_media' => false, + 'accepts_gif' => false, + 'max_files' => 10, + 'max_document_bytes' => min(100 * $mb, $hardDocument), + 'max_video_bytes' => $hardVideo, + ], + 'x_post' => [ + 'requires_media' => false, + 'accepts_gif' => true, + 'max_files' => 4, + 'max_video_duration_sec' => 140, + 'max_video_bytes' => min(512 * $mb, $hardVideo), + ], + 'discord_message' => [ + 'requires_media' => false, + 'accepts_gif' => true, + 'max_files' => 10, + ], + 'telegram_post' => [ + 'requires_media' => false, + 'accepts_gif' => true, + 'max_files' => 10, + ], + ]; + + foreach ($expected as $type => $fields) { + $rules = ContentType::from($type)->mediaRules(); + + foreach ($fields as $key => $value) { + expect($rules[$key])->toBe($value, "{$type}.{$key}"); + } + } +}); + +test('media byte caps never exceed the global upload hard limits', function () { + $hardImage = MediaType::Image->maxSizeInBytes(); + $hardVideo = MediaType::Video->maxSizeInBytes(); + $hardDocument = MediaType::Document->maxSizeInBytes(); + + foreach (ContentType::cases() as $type) { + $image = $type->maxImageBytes(); + $video = $type->maxVideoBytes(); + $document = $type->maxDocumentBytes(); + + if ($image !== null) { + expect($image)->toBeLessThanOrEqual($hardImage, "{$type->value}.max_image_bytes"); + } + + if ($video !== null) { + expect($video)->toBeLessThanOrEqual($hardVideo, "{$type->value}.max_video_bytes"); + } + + if ($document !== null) { + expect($document)->toBeLessThanOrEqual($hardDocument, "{$type->value}.max_document_bytes"); + } + } + + expect(ContentType::YouTubeShort->maxVideoBytes())->toBe($hardVideo) + ->and(ContentType::PinterestPin->maxImageBytes())->toBe($hardImage) + ->and(ContentType::FacebookPost->maxVideoBytes())->toBe($hardVideo); +}); + test('can get content types for platform', function () { $instagramTypes = ContentType::forPlatform(Platform::Instagram); diff --git a/tests/Unit/Policies/SocialAccountPolicyTest.php b/tests/Unit/Policies/SocialAccountPolicyTest.php new file mode 100644 index 000000000..99a6bc90e --- /dev/null +++ b/tests/Unit/Policies/SocialAccountPolicyTest.php @@ -0,0 +1,40 @@ +policy = new SocialAccountPolicy; +}); + +test('members of the current workspace can view a social account', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $workspace->members()->attach($user->id, ['role' => Role::Member->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $account = SocialAccount::factory()->create(['workspace_id' => $workspace->id]); + + expect($this->policy->view($user->fresh(), $account))->toBeTrue(); +}); + +test('cross-workspace social account lookups deny as not found', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $workspace->members()->attach($user->id, ['role' => Role::Member->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $otherWorkspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->create(['workspace_id' => $otherWorkspace->id]); + + $result = $this->policy->view($user->fresh(), $account); + + expect($result)->toBeInstanceOf(Response::class) + ->and($result->status())->toBe(404); +});