Merge pull request #205 from trypostit/fix/mcp-upload-rate-limit-and-reel-durations

Fix MCP upload rate limits and Instagram Reel duration caps
This commit is contained in:
Paulo Castellano 2026-07-24 23:57:03 -03:00 committed by GitHub
commit b16e0bd9a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
86 changed files with 2136 additions and 302 deletions

View file

@ -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<int, SocialAccount>,
* pinterestBoards: SupportCollection<string, array<int, mixed>>,
* pinterestBoards: SupportCollection<string, array{boards: list<array{id: string, name: string}>, truncated: bool}>,
* tiktokCreatorInfos: SupportCollection<string, mixed>,
* }
*/
@ -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,
),
]);

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Actions\SocialAccount;
use App\Models\SocialAccount;
use App\Services\Social\Discord\DiscordClient;
class ListDiscordChannels
{
/**
* @return list<array{id: string, name: string}>
*/
public static function execute(SocialAccount $account): array
{
return app(DiscordClient::class)->channels((string) $account->platform_user_id);
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Actions\SocialAccount;
use App\Models\SocialAccount;
use App\Services\Social\PinterestPublisher;
use Illuminate\Support\Collection;
class ListPinterestBoards
{
/**
* @return array{boards: list<array{id: string, name: string}>, 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),
];
}
}

View file

@ -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<string, array<string, mixed>>
*/
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,
];
}

View file

@ -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) {
return response()->json(
['message' => 'Account not found.'],
Response::HTTP_NOT_FOUND,
);
}
$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' => 'Boards are only available for Pinterest accounts.'],
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
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),
);
}
}
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,
};
}
}

View file

@ -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();
$media = DB::transaction(function () use ($workspace, $request, $token): Media {
$media = $workspace->addMedia($request->file('media'), 'assets');
$media->upload_token = $token;
$media->save();
// 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.');
}
return $media;
});
$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;
});
} 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()

View file

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

View file

@ -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<string, callable>
*/
public function shareOnce(Request $request): array
{
return [
...parent::shareOnce($request),
'contentTypeMediaRules' => fn (): array => ContentType::mediaRulesForFrontend(),
];
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\SocialAccount;
use App\Actions\SocialAccount\ListDiscordChannels;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\PlatformUnavailableException;
use App\Models\SocialAccount;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('List Discord text/announcement channels the bot can post to for a connected Discord server. Use the returned channel id as platforms[].meta.channel_id when creating or updating a Discord post (required to publish).')]
class ListDiscordChannelsTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->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.'),
];
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\SocialAccount;
use App\Actions\SocialAccount\ListPinterestBoards;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\PinterestPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('List Pinterest boards for a connected Pinterest account. Use the returned board id as platforms[].meta.board_id when creating or updating a Pinterest post (required to publish).')]
class ListPinterestBoardsTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->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.'),
];
}
}

View file

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

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\SocialAccount;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class SocialAccountPolicy
{
/**
* Authorize access to a social account. Must live in the user's current
* workspace; cross-workspace lookups deny as 404 so we don't leak
* existence across tenants (same tenancy pattern as PostPolicy).
*/
public function view(User $user, SocialAccount $account): bool|Response
{
if ($account->workspace_id !== $user->current_workspace_id) {
return Response::denyAsNotFound();
}
return true;
}
}

View file

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

View file

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

View file

@ -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<array<string, mixed>>, 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;
if ($response->failed()) {
Log::error('Pinterest get boards failed', ['body' => $this->redactResponseBody($response->body())]);
$this->handleApiError($response);
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;
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

View file

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

View file

@ -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),
],
/*

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'أطلق طلب HTTP استثناءً.',
'http_request_failed' => 'فشل طلب HTTP.',
'http_items_path_not_array' => 'لم يُفضِ مسار العناصر إلى قائمة.',
'generate_image_format_required' => 'توليد الذكاء الاصطناعي ينشئ صورًا فقط. اختر تنسيق صورة (وليس فيديو).',
],
];

View file

@ -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' => 'قصة',

View file

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

View file

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

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'Το αίτημα HTTP προκάλεσε εξαίρεση.',
'http_request_failed' => 'Το αίτημα HTTP απέτυχε.',
'http_items_path_not_array' => 'Η διαδρομή στοιχείων δεν αντιστοιχήθηκε σε λίστα.',
'generate_image_format_required' => 'Η δημιουργία AI παράγει μόνο εικόνες. Επίλεξε μορφή εικόνας (όχι βίντεο).',
],
];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 nont pas pu être chargés. Sil 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',

View file

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

View file

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

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'HTTP リクエストで例外が発生しました。',
'http_request_failed' => 'HTTP リクエストが失敗しました。',
'http_items_path_not_array' => 'アイテムのパスがリストとして解決されませんでした。',
'generate_image_format_required' => 'AI生成は画像のみ作成します。画像フォーマットを選んでください動画不可。',
],
];

View file

@ -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' => 'ストーリー',

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'HTTP 요청에서 예외가 발생했습니다.',
'http_request_failed' => 'HTTP 요청이 실패했습니다.',
'http_items_path_not_array' => '항목 경로가 목록으로 해석되지 않았습니다.',
'generate_image_format_required' => 'AI 생성은 이미지만 만듭니다. 이미지 형식을 선택하세요(동영상 불가).',
],
];

View file

@ -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' => '스토리',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'HTTP-запрос вызвал исключение.',
'http_request_failed' => 'HTTP-запрос не удался.',
'http_items_path_not_array' => 'Путь к элементам не привёл к списку.',
'generate_image_format_required' => 'ИИ генерирует только изображения. Выберите формат изображения (не видео).',
],
];

View file

@ -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' => 'История',

View file

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

View file

@ -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 Pinteresti 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',

View file

@ -408,5 +408,6 @@
'http_request_exception' => 'HTTP 请求抛出了异常。',
'http_request_failed' => 'HTTP 请求失败。',
'http_items_path_not_array' => '条目路径未解析为列表。',
'generate_image_format_required' => 'AI 生成仅创建图片。请选择图片格式(不支持视频)。',
],
];

View file

@ -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' => '快拍',

View file

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

View file

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

View file

@ -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<Record<string, any>>(() => {
return raw ?? {};
});
const pinterestBoards = computed<Record<string, PinterestBoard[]>>(() => {
const raw = page.props.pinterestBoards as Record<string, PinterestBoard[]> | undefined;
const pinterestBoards = computed<Record<string, PinterestBoardsPayload>>(() => {
const raw = page.props.pinterestBoards as Record<string, PinterestBoardsPayload> | 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<Channel[]>(() =>
publishConfig: getPublishConfig(account),
creatorInfo: getCreatorInfo(account),
boards: getBoards(account),
boardsTruncated: boardsTruncated(account),
};
}),
);

View file

@ -1,10 +1,11 @@
<script setup lang="ts">
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { fallbackImageCapableVariant, filterImageCapableVariants } from '@/lib/aiGenerateVariants';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';
@ -38,11 +39,24 @@ const emit = defineEmits<{
const open = ref(false);
const variants = [
const allVariants = [
{ value: ContentType.FacebookPost, labelKey: 'posts.form.facebook.variant.post' },
{ value: ContentType.FacebookReel, labelKey: 'posts.form.facebook.variant.reel' },
{ value: ContentType.FacebookStory, labelKey: 'posts.form.facebook.variant.story' },
];
] 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 aspectRatios = [
{ value: '1:1', labelKey: 'posts.form.facebook.aspect.square' },

View file

@ -1,10 +1,11 @@
<script setup lang="ts">
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { fallbackImageCapableVariant, filterImageCapableVariants } from '@/lib/aiGenerateVariants';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';
@ -38,11 +39,24 @@ const emit = defineEmits<{
const open = ref(false);
const variants = [
const allVariants = [
{ value: ContentType.InstagramFeed, labelKey: 'posts.form.instagram.variant.feed' },
{ value: ContentType.InstagramReel, labelKey: 'posts.form.instagram.variant.reel' },
{ value: ContentType.InstagramStory, labelKey: 'posts.form.instagram.variant.story' },
];
] 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 aspectRatios = [
{ value: '1:1', labelKey: 'posts.form.instagram.aspect.square' },

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Avatar } from '@/components/ui/avatar';
@ -17,6 +17,7 @@ import {
import { getMediaValidationWarning } from '@/composables/useMedia';
import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { fallbackImageCapableVariant, filterImageCapableVariants } from '@/lib/aiGenerateVariants';
import type { PinterestBoard } from '@/types';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';
@ -39,6 +40,7 @@ interface Props {
contentType: string;
media: MediaItem[];
boards: PinterestBoard[];
boardsTruncated?: boolean;
meta: Record<string, any>;
disabled?: boolean;
previewOnly?: boolean;
@ -47,6 +49,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
disabled: false,
previewOnly: false,
boardsTruncated: false,
});
const emit = defineEmits<{
@ -56,11 +59,25 @@ const emit = defineEmits<{
const open = ref(false);
const variants = [
const allVariants = [
{ value: ContentType.PinterestPin, labelKey: 'posts.form.pinterest.variant.pin' },
{ value: ContentType.PinterestVideoPin, labelKey: 'posts.form.pinterest.variant.video_pin' },
{ value: ContentType.PinterestCarousel, labelKey: 'posts.form.pinterest.variant.carousel' },
];
] as const;
// Generate node only creates images hide Video Pin there.
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;
@ -187,6 +204,13 @@ const boardError = computed<string | undefined>(() => {
</ComboboxList>
</Combobox>
<InputError :message="boardError" />
<p
v-if="boardsTruncated"
class="flex items-start gap-2 rounded-lg border-2 border-foreground/30 bg-foreground/5 p-2 text-xs font-semibold text-foreground/60"
>
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
{{ $t('posts.form.pinterest.boards_truncated') }}
</p>
</template>
</div>

View file

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

View file

@ -8,7 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { isVideo } from '@/lib/mediaType';
import type { PinterestBoard } from '@/types';
import type { PinterestBoard, PinterestBoardsPayload } from '@/types';
import type { Channel } from '@/types/channel';
import type { MediaItem } from '@/types/media';
import { PostPlatformStatus } from '@/types/post';
@ -77,7 +77,7 @@ const props = defineProps<{
platformContentTypes: Record<string, string>;
platformIssues?: Record<string, string>;
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
pinterestBoards?: Record<string, PinterestBoard[]> | null;
pinterestBoards?: Record<string, PinterestBoardsPayload> | null;
media?: MediaItem[];
}>();
@ -94,8 +94,14 @@ const getPublishConfig = (pp: PostPlatform): Record<string, any> | null =>
const getCreatorInfo = (pp: PostPlatform): TikTokCreatorInfo | null =>
pp.social_account_id ? props.tiktokCreatorInfos?.[pp.social_account_id] ?? null : null;
const getBoards = (pp: PostPlatform): PinterestBoard[] =>
pp.social_account_id ? props.pinterestBoards?.[pp.social_account_id] ?? [] : [];
const boardsPayload = (pp: PostPlatform): PinterestBoardsPayload =>
pp.social_account_id
? props.pinterestBoards?.[pp.social_account_id] ?? { boards: [], truncated: false }
: { boards: [], truncated: false };
const getBoards = (pp: PostPlatform): PinterestBoard[] => boardsPayload(pp).boards;
const boardsTruncated = (pp: PostPlatform): boolean => boardsPayload(pp).truncated;
const videoDurationSec = computed(() => {
const video = props.media?.find((m) => isVideo(m));
@ -143,6 +149,7 @@ const channels = computed<Channel[]>(() =>
publishConfig: getPublishConfig(pp),
creatorInfo: getCreatorInfo(pp),
boards: getBoards(pp),
boardsTruncated: boardsTruncated(pp),
})),
);
</script>

View file

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

View file

@ -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<string, MediaRules> = {
// 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<string> | ComputedRef<string>) {
const rules = computed<MediaRules>(() => {
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<string> | ComputedRef<string>) => {
const rules = computed<MediaRules>(() => getMediaRulesForContentType(contentType.value));
const acceptMimeTypes = computed<string>(() => {
const types: string[] = [];
@ -214,8 +93,4 @@ export function useMediaRules(contentType: Ref<string> | ComputedRef<string>) {
isValidFileType,
getAcceptDescription,
};
}
export function getMediaRulesForContentType(contentType: string): MediaRules {
return CONTENT_TYPE_RULES[contentType] || DEFAULT_RULES;
}
};

View file

@ -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 = <T extends { value: string }>(
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;
};

View file

@ -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<string, ContentTypeMediaRule>;
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;
};

View file

@ -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<string, any>;
pinterestBoards: Record<string, PinterestBoard[]>;
pinterestBoards: Record<string, PinterestBoardsPayload>;
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
labels: { id: string; name: string; color: string }[];
signatures: { id: string; name: string; content: string }[];

View file

@ -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<DefineComponent>('./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 },
);

View file

@ -40,4 +40,5 @@ export interface Channel {
publishConfig?: Record<string, any> | null;
creatorInfo?: ChannelTikTokCreatorInfo | null;
boards?: PinterestBoard[];
boardsTruncated?: boolean;
}

View file

@ -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<string, ContentTypeMediaRule>;
[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;

View file

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

View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
Cache::flush();
config([
'trypost.platforms.discord.bot_token' => '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();
});

View file

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$result = createApiTestToken();
$this->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();
});

View file

@ -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 () {

View file

@ -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,11 @@ 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', 51 * 1024 + 1, 'video/mp4');
$file = UploadedFile::fake()->create('huge.mp4', 1024 + 1, 'video/mp4');
$response = $this->postJson(signedUploadUrl($this->workspace, $token), ['media' => $file]);
@ -112,6 +115,67 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes =
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('big.jpg', 1024 + 1, 'image/jpeg');
$response = $this->postJson(signedUploadUrl($this->workspace, $token), ['media' => $file]);
$response->assertStatus(422);
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)],
);
$response->assertStatus(429);
)->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);
$this->postJson(
signedUploadUrl($otherWorkspace, (string) Str::uuid()),
['media' => UploadedFile::fake()->image('other.png', 16, 16)],
)->assertSuccessful();
});

View file

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

View file

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

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$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('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)
);
});

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Mcp\Servers\TryPostServer;
use App\Mcp\Tools\SocialAccount\ListDiscordChannelsTool;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Testing\Fluent\AssertableJson;
beforeEach(function () {
Cache::flush();
config([
'trypost.platforms.discord.bot_token' => '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.']);
});

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Mcp\Servers\TryPostServer;
use App\Mcp\Tools\SocialAccount\ListPinterestBoardsTool;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Illuminate\Testing\Fluent\AssertableJson;
beforeEach(function () {
$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 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();
});

View file

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

View file

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

View file

@ -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 () {

View file

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

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Policies\SocialAccountPolicy;
use Illuminate\Auth\Access\Response;
beforeEach(function () {
$this->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);
});