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:
commit
b16e0bd9a5
86 changed files with 2136 additions and 302 deletions
|
|
@ -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,
|
||||
),
|
||||
]);
|
||||
|
|
|
|||
19
app/Actions/SocialAccount/ListDiscordChannels.php
Normal file
19
app/Actions/SocialAccount/ListDiscordChannels.php
Normal 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);
|
||||
}
|
||||
}
|
||||
34
app/Actions/SocialAccount/ListPinterestBoards.php
Normal file
34
app/Actions/SocialAccount/ListPinterestBoards.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
];
|
||||
|
|
|
|||
53
app/Mcp/Tools/SocialAccount/ListDiscordChannelsTool.php
Normal file
53
app/Mcp/Tools/SocialAccount/ListDiscordChannelsTool.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
54
app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php
Normal file
54
app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
26
app/Policies/SocialAccountPolicy.php
Normal file
26
app/Policies/SocialAccountPolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -408,5 +408,6 @@
|
|||
'http_request_exception' => 'أطلق طلب HTTP استثناءً.',
|
||||
'http_request_failed' => 'فشل طلب HTTP.',
|
||||
'http_items_path_not_array' => 'لم يُفضِ مسار العناصر إلى قائمة.',
|
||||
'generate_image_format_required' => 'توليد الذكاء الاصطناعي ينشئ صورًا فقط. اختر تنسيق صورة (وليس فيديو).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => 'قصة',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -408,5 +408,6 @@
|
|||
'http_request_exception' => 'Το αίτημα HTTP προκάλεσε εξαίρεση.',
|
||||
'http_request_failed' => 'Το αίτημα HTTP απέτυχε.',
|
||||
'http_items_path_not_array' => 'Η διαδρομή στοιχείων δεν αντιστοιχήθηκε σε λίστα.',
|
||||
'generate_image_format_required' => 'Η δημιουργία AI παράγει μόνο εικόνες. Επίλεξε μορφή εικόνας (όχι βίντεο).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@
|
|||
'board' => 'Tableau',
|
||||
'select_board' => 'Sélectionner un tableau',
|
||||
'no_boards' => 'Aucun tableau Pinterest trouvé. Créez-en un dans votre compte Pinterest d\'abord.',
|
||||
'boards_truncated' => 'Certains tableaux n’ont pas pu être chargés. S’il manque le vôtre, ouvrez Pinterest et réessayez.',
|
||||
'search_board' => 'Rechercher des tableaux...',
|
||||
'no_board_found' => 'Aucun tableau ne correspond à votre recherche.',
|
||||
'board_required' => 'Sélectionnez un tableau Pinterest pour publier cette publication.',
|
||||
|
|
@ -464,7 +465,7 @@
|
|||
],
|
||||
'instagram_reel' => [
|
||||
'label' => 'Reel',
|
||||
'description' => 'Courte vidéo jusqu\'à 90 secondes',
|
||||
'description' => 'Courte vidéo jusqu\'à 15 minutes',
|
||||
],
|
||||
'instagram_story' => [
|
||||
'label' => 'Story',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -408,5 +408,6 @@
|
|||
'http_request_exception' => 'HTTP リクエストで例外が発生しました。',
|
||||
'http_request_failed' => 'HTTP リクエストが失敗しました。',
|
||||
'http_items_path_not_array' => 'アイテムのパスがリストとして解決されませんでした。',
|
||||
'generate_image_format_required' => 'AI生成は画像のみ作成します。画像フォーマットを選んでください(動画不可)。',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => 'ストーリー',
|
||||
|
|
|
|||
|
|
@ -408,5 +408,6 @@
|
|||
'http_request_exception' => 'HTTP 요청에서 예외가 발생했습니다.',
|
||||
'http_request_failed' => 'HTTP 요청이 실패했습니다.',
|
||||
'http_items_path_not_array' => '항목 경로가 목록으로 해석되지 않았습니다.',
|
||||
'generate_image_format_required' => 'AI 생성은 이미지만 만듭니다. 이미지 형식을 선택하세요(동영상 불가).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => '스토리',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -408,5 +408,6 @@
|
|||
'http_request_exception' => 'HTTP-запрос вызвал исключение.',
|
||||
'http_request_failed' => 'HTTP-запрос не удался.',
|
||||
'http_items_path_not_array' => 'Путь к элементам не привёл к списку.',
|
||||
'generate_image_format_required' => 'ИИ генерирует только изображения. Выберите формат изображения (не видео).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => 'История',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@
|
|||
'board' => 'Pano',
|
||||
'select_board' => 'Bir pano seçin',
|
||||
'no_boards' => 'Pinterest panosu bulunamadı. Önce Pinterest hesabınızda bir tane oluşturun.',
|
||||
'boards_truncated' => 'Bazı panolar yüklenemedi. Sizinki eksikse Pinterest’i açıp tekrar deneyin.',
|
||||
'search_board' => 'Pano ara...',
|
||||
'no_board_found' => 'Aramanızla eşleşen pano yok.',
|
||||
'board_required' => 'Bu gönderiyi yayınlamak için bir Pinterest panosu seçin.',
|
||||
|
|
@ -466,7 +467,7 @@
|
|||
],
|
||||
'instagram_reel' => [
|
||||
'label' => 'Reel',
|
||||
'description' => '90 saniyeye kadar kısa video',
|
||||
'description' => '15 dakikaya kadar kısa video',
|
||||
],
|
||||
'instagram_story' => [
|
||||
'label' => 'Hikaye',
|
||||
|
|
|
|||
|
|
@ -408,5 +408,6 @@
|
|||
'http_request_exception' => 'HTTP 请求抛出了异常。',
|
||||
'http_request_failed' => 'HTTP 请求失败。',
|
||||
'http_items_path_not_array' => '条目路径未解析为列表。',
|
||||
'generate_image_format_required' => 'AI 生成仅创建图片。请选择图片格式(不支持视频)。',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => '快拍',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
32
resources/js/lib/aiGenerateVariants.ts
Normal file
32
resources/js/lib/aiGenerateVariants.ts
Normal 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;
|
||||
};
|
||||
109
resources/js/lib/contentTypeMediaRules.ts
Normal file
109
resources/js/lib/contentTypeMediaRules.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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 }[];
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -40,4 +40,5 @@ export interface Channel {
|
|||
publishConfig?: Record<string, any> | null;
|
||||
creatorInfo?: ChannelTikTokCreatorInfo | null;
|
||||
boards?: PinterestBoard[];
|
||||
boardsTruncated?: boolean;
|
||||
}
|
||||
|
|
|
|||
25
resources/js/types/index.d.ts
vendored
25
resources/js/types/index.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
94
tests/Feature/Api/DiscordChannelsApiTest.php
Normal file
94
tests/Feature/Api/DiscordChannelsApiTest.php
Normal 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();
|
||||
});
|
||||
137
tests/Feature/Api/PinterestBoardsApiTest.php
Normal file
137
tests/Feature/Api/PinterestBoardsApiTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
32
tests/Feature/ContentTypeMediaRulesShareTest.php
Normal file
32
tests/Feature/ContentTypeMediaRulesShareTest.php
Normal 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)
|
||||
);
|
||||
});
|
||||
95
tests/Feature/Mcp/ListDiscordChannelsToolTest.php
Normal file
95
tests/Feature/Mcp/ListDiscordChannelsToolTest.php
Normal 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.']);
|
||||
});
|
||||
98
tests/Feature/Mcp/ListPinterestBoardsToolTest.php
Normal file
98
tests/Feature/Mcp/ListPinterestBoardsToolTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
40
tests/Unit/Policies/SocialAccountPolicyTest.php
Normal file
40
tests/Unit/Policies/SocialAccountPolicyTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue