Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
790854a
feat: validate optional YouTube descriptions
Sep 27, 2026
2fc70ed
feat: expose YouTube descriptions through API and MCP
Sep 27, 2026
ebed296
feat: publish and preview separate YouTube descriptions
Sep 27, 2026
46d54b5
feat: add YouTube description settings to the editor
Sep 27, 2026
aca28a9
style: align YouTube description files with project conventions
Sep 27, 2026
8cacb32
refactor: remove unused channel preview-only flag
Sep 27, 2026
de2dffc
fix: simplify YouTube description limit messages
Sep 27, 2026
ff8caf3
refactor: share YouTube description normalization and validation
Sep 27, 2026
30e90f4
test: verify YouTube publishing through HTTP requests
Sep 27, 2026
6c832f6
refactor: simplify YouTube settings error state
Sep 27, 2026
8c36b90
refactor: simplify YouTube upload metadata preparation
Sep 27, 2026
fc2feb8
refactor: simplify YouTube description client validation
Sep 27, 2026
c2624cc
test: keep frontend coverage in browser tests
Sep 27, 2026
fc6b0f6
refactor: remove unnecessary Unicode checks from YouTube editor
Sep 27, 2026
95ae231
refactor: simplify blank YouTube description handling
Sep 27, 2026
5ff7219
refactor: share nullable text helper across channel settings
Sep 27, 2026
1e0ee16
refactor: reuse shared publication metadata validation on update
Sep 27, 2026
84771ef
fix: align YouTube description helpers and cover publishing boundaries
Sep 27, 2026
e5dee76
fix: accept literal angle brackets in YouTube descriptions
Sep 27, 2026
5df30df
refactor: simplify YouTube upload lifecycle
Sep 27, 2026
1d09048
test: make calendar locale checks independent of current date
Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/Actions/Post/UpdatePost.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\Workspace;
use App\Support\PostPlatformMetaRules;
use App\Support\PostStatusRules;
use App\Support\Social\AbandonGoogleBusinessReview;
use App\Support\Social\GoogleBusinessDerivativeCleaner;
Expand Down Expand Up @@ -74,7 +75,16 @@ public static function execute(Workspace $workspace, Post $post, array $data): a
->where('id', data_get($platformData, 'id'))
->update($updateData);
}
}

if (in_array($post->status, [PostStatus::Scheduled, PostStatus::Publishing], true)) {
PostPlatformMetaRules::assertStoredPostPublishable(
$post,
collect(data_get($data, 'platforms', []))->pluck('id')->all(),
);
}

if (Arr::has($data, 'platforms')) {
$post->postPlatforms()
->disabled()
->where('platform', Platform::GoogleBusiness)
Expand Down
8 changes: 4 additions & 4 deletions app/Enums/SocialAccount/Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,10 @@ public function supportsAltText(): bool
* - LinkedIn UGC: 3000 (`commentary` field)
* - X standard tweet: 280 (X Premium accepts 25K — ignored, conservative)
* - TikTok caption: 2200
* - YouTube Shorts: title=100, description=5000. We feed `content` to both
* (publisher derives title from the first line via `buildTitle`), and
* Shorts UX only shows ~100 chars before "more" — capping at 100 keeps
* posts appropriate for the format.
* - YouTube Shorts: content supplies the title, capped at 100 characters
* (publisher derives it from the first line via `buildTitle`). Optional
* meta.description is separate plain text, capped at 5000 UTF-8 bytes;
* absent descriptions fall back to content.
* - Facebook text status: 10000 (API allows 63206; we cap below
* that — 63k-char posts are unrealistic and emoji-heavy content
* risks overflowing the TEXT column's 65535-byte ceiling)
Expand Down
2 changes: 1 addition & 1 deletion app/Mcp/Tools/Post/CreatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,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 PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), 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 PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]). YouTube Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description or pass null to use the post content as description.'),
]))
->description('Platforms to publish on. Accounts not listed remain available but disabled.'),
];
Expand Down
2 changes: 1 addition & 1 deletion app/Mcp/Tools/Post/UpdatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,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 PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta.'),
'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta. YouTube Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description to keep the current override; pass null to remove it and use the post content as description.'),
]))
->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'),
];
Expand Down
25 changes: 25 additions & 0 deletions app/Rules/ValidYouTubeDescription.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Rules;

use App\Support\YouTubeDescription;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;

class ValidYouTubeDescription implements ValidationRule
{
/**
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$key = YouTubeDescription::violation($value);

if ($key !== null) {
$fail($key)->translate();
}
}
}
16 changes: 14 additions & 2 deletions app/Services/Post/PostPreviewer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

namespace App\Services\Post;

use App\Enums\SocialAccount\Platform;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Services\Social\ContentSanitizer;
use App\Support\YouTubeDescription;
use Illuminate\Support\Collection;

/**
Expand All @@ -31,7 +33,9 @@ public function __construct(private readonly ContentSanitizer $sanitizer) {}
* sanitized_content: string,
* sanitized_length: int,
* max_content_length: int,
* truncated: bool
* truncated: bool,
* description?: string,
* description_length_bytes?: int
* }>
* }
*/
Expand Down Expand Up @@ -59,7 +63,7 @@ private function platformPreviews(Post $post, string $original): Collection
$platform = $pp->socialAccount?->platform ?? $pp->platform;
$sanitized = $this->sanitizer->sanitize($original, $platform);

return [
$preview = [
'post_platform_id' => $pp->id,
'platform' => $platform->value,
'content_type' => $pp->content_type?->value,
Expand All @@ -68,6 +72,14 @@ private function platformPreviews(Post $post, string $original): Collection
'max_content_length' => $platform->maxContentLength(),
'truncated' => mb_strlen($sanitized) < mb_strlen($original),
];

if ($platform === Platform::YouTube) {
$description = YouTubeDescription::resolve($pp->meta, $sanitized);
$preview['description'] = $description;
$preview['description_length_bytes'] = strlen($description);
}

return $preview;
});
}
}
Loading
Loading