Commit graph

901 commits

Author SHA1 Message Date
Paulo Castellano
bfa018a4cd
Merge pull request #122 from trypostit/fix/api-media-download-host
fix: make external-URL media safe end-to-end (API hosting, X MIME, error sanitization)
2026-06-29 13:48:07 -03:00
Paulo Castellano
a63e9ea778 fix(media): treat any download failure (timeout/DNS/refused) as a failed fetch
MediaAttacher::download() only caught RuntimeException, but a connection-level
failure throws Illuminate\Http\Client\ConnectionException (extends Exception, not
RuntimeException) — e.g. a slow/unreachable proxy hitting the 20s timeout. On the
API hosting path that propagated as a 500 (not the promised 422), skipped the
batch rollback (orphaning an already-hosted item), and leaked the temp file.

Catch Throwable so any fetch failure returns null → the caller rejects cleanly
with 422 and rolls back. Also hardens the existing MCP/REST attach-from-url paths
(a timeout there now reports a failed URL instead of 500).
2026-06-29 13:41:47 -03:00
Paulo Castellano
a6bb39cc1a refactor(api): extract inline-media hosting into a HostInlineMedia action
The download+host+422 orchestration was a private controller method doing IO and
throwing — that's an operation, not a controller concern. Move it to
App\Actions\Post\HostInlineMedia::execute() (alongside CreatePost/UpdatePost) so
the controller stays thin and the logic is reusable/testable.
2026-06-28 21:15:01 -03:00
Paulo Castellano
41cb9b02a3 test(api): cover unsupported-type and mixed-batch inline media scenarios
- External URL that downloads (200) but isn't a supported media type → 422,
  nothing persisted (the type-rejection branch, distinct from a download 404).
- Mixed batch: an already-hosted item + an external URL both succeed → both kept
  in order, only the external one creates a Media row.
2026-06-28 21:15:01 -03:00
Paulo Castellano
df891574f7 fix(api): roll back hosted media when an inline url batch partially fails
Final-review follow-ups:
- MediaAttacher::resolveInlineMedia now deletes the media it hosted in this call
  when any item fails, so a mixed [good, bad] batch no longer orphans the good
  item's Media row + file while the request is correctly rejected with 422. Makes
  the create/update media resolution truly all-or-nothing.
- PostMediaRules: keep source/source_meta on both contracts (the API previously
  passed them through with no item rules — don't silently drop them) so the media
  item shape is uniform; only id/path/url differ by contract.
- Make MediaAttacher::fetchToWorkspace private (no external callers).
- Test the partial-batch rollback (no Media, no post persisted).
2026-06-28 20:59:21 -03:00
Paulo Castellano
1242cc7384 refactor(posts): centralize inline media validation in PostMediaRules
The media.* rules were duplicated across the web update request and both API
requests (and diverged: web requires hosted id+path and tracks source; the API
accepts a bare external url it downloads). Pull them into one
App\Support\PostMediaRules::rules(hosted:) — same pattern as PostPlatformMetaRules
— parameterized by contract, so there's a single place to add a media key and the
validated()-strips-unlisted-keys footgun can't drift between entry points.

Behavior is unchanged (each ruleset is reproduced exactly). Web store keeps its
loose 'media' => array (no item rules) and is left out on purpose — adding strict
rules there would change the web create contract.
2026-06-28 20:43:09 -03:00
Paulo Castellano
8d7dcdf6eb refactor(social): trim verbose comments + harden X chunked upload from review
Cold-review follow-ups on the PR:
- Trim the oversized docblocks/inline comments added across the API controller,
  MediaAttacher, Post, the publish job, and the X publisher to one line (keeping
  the @param/@return array-shape annotations).
- XPublisher::chunkedUpload now accepts ?string $mediaCategory and only sends
  media_category when present — getMediaCategory() can return null, so the strict
  string param was a latent TypeError (unreachable on X today, removed anyway).
- Fix MediaAttacher docblocks: the file imports Type as MediaType, so the
  @param array<Type> annotations didn't resolve — now array<MediaType>.
- Tests: cover the failed() job hook genericizing a raw error, and X failing
  cleanly (XPublishException) when media can't be downloaded.
2026-06-28 20:37:04 -03:00
Paulo Castellano
5b26747dde refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.

Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).

Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 20:16:16 -03:00
Paulo Castellano
08e64ae074 fix(publish): never surface raw internal errors in the failure email
The post-failure email renders each platform's stored error_message verbatim.
On an unexpected publish error the job's catch-all (and the job-failed hook)
stored the raw exception message — for a PHP TypeError that includes the server
file path (.../releases/<id>/app/Services/Social/XPublisher.php on line 130),
which then reached the customer's inbox.

Only our own SocialPublishException carries a vetted, user-facing message.
Every other throwable (engine errors like TypeError, or library exceptions such
as Intervention's decoder errors which embed temp paths) is now replaced with a
generic line; the raw detail stays in the logs. Token-expired and rescheduled
paths are unchanged.

Note: publishers that still throw plain \Exception for user-facing reasons
(some X/Facebook/Threads/LinkedIn cases) will now show the generic message for
those; converting them to typed SocialPublishExceptions to restore specific
copy is a worthwhile follow-up.
2026-06-28 20:02:35 -03:00
Paulo Castellano
4417aa99ac fix(x): recover a missing media MIME from the downloaded bytes
A post created via the API with bare external-URL media (no mime_type, no
path to infer one from) reached XPublisher::getMediaCategory() with a null
$mimeType — whose parameter is typed string — throwing a TypeError. The
TypeError's message embeds the server file path, which then surfaced verbatim
in the user's failure email.

X downloads the media bytes anyway, so recover the MIME from the downloaded
file when the item carries none, and fail with a clean XPublishException
(MediaFormat) rather than a TypeError when it still can't be determined. Also
drive the image/video decision off the resolved MIME so sniffed images are
still optimized.

Audited every publisher: X was the only one whose strict mime path was
reachable with null (no isImage() gate + no path to backfill from). Bluesky's
uploadBlob is also strictly typed but is guarded by an isImage() check plus the
DTO's extension-based MIME backfill; the pull-from-URL publishers classify via
nullable helpers and fail cleanly; Pinterest/Mastodon already sniff; YouTube
and Facebook video fall back to video/mp4.
2026-06-28 19:29:14 -03:00
Paulo Castellano
81d43c30f4 fix(api): download and host external media URLs on post create/update
The public REST API accepted inline post media as a free-form array and stored
it verbatim, so a client could create/update a post whose media was a bare
external URL we never hosted. Publishing then depended on that third-party URL
staying alive — when it 404'd (e.g. an image proxy), the post failed across
platforms (Facebook 'unsupported media type', X 'HTTP 404', Instagram 'could
not fetch media').

Inline media URLs on create/update now go through the same download + MIME-
validate + host path as the attach-from-url endpoint (MediaAttacher), so the
stored media always points at our own storage. Items already hosted (carrying a
path) pass through untouched. If any URL can't be fetched the request is
rejected with 422 and nothing is persisted, so a post is never created with
broken media. MCP and the web flow were already safe and are unchanged.

- MediaAttacher: extract fetchToWorkspace() + add resolveInlineMedia()
- Post::allowedMediaTypesFor() so the create flow can compute allowed types
  without a persisted post
- API Store/UpdatePostRequest: media.* item rules (mirroring the web; prevents
  validated() from stripping hosted-item keys)
- PostController store()/update(): host external media before persisting
2026-06-28 17:28:05 -03:00
Paulo Castellano
80dc1ab381
Merge pull request #120 from trypostit/fix/tiktok-photo-resize
fix(tiktok): resize oversized photos before PULL_FROM_URL
2026-06-26 13:36:38 -03:00
Paulo Castellano
ffa4377615 test(tiktok): fake against the config base URL across all TikTok test files
Convert TikTokCreatorInfoTest and TikTokPublishExceptionTest to fake against
config('trypost.platforms.tiktok.api') instead of the hardcoded host, matching
the TikTokPublisherTest cleanup. TikTokControllerTest is left as-is: its only
TikTok URL is a Socialite authorize stub, which has no config counterpart.
2026-06-26 13:13:15 -03:00
Paulo Castellano
5df854092b test(tiktok): cover cleanup edge cases and fake against the config base URL
- Multi-image carousel: only the oversized photo is resized, order preserved,
  the compliant one passes through untouched.
- Cleanup runs even when publishing fails (no orphaned derivative).
- A throwing Storage::delete does not turn a successful publish into a failure.
- Replace hardcoded https://open.tiktokapis.com/v2 with
  config('trypost.platforms.tiktok.api') so the fakes can't drift from the
  configured (overridable) host.
2026-06-26 13:08:18 -03:00
Paulo Castellano
3cf9af9caf fix(tiktok): keep photo derivative cleanup from masking a successful publish
The default disk (r2) is configured with throw => true, so Storage::delete
re-throws on a failed delete. Running that inside the finally meant a delete
failure could discard the publishPhotos return value and surface an
already-live TikTok post as a failure (risking a duplicate re-publish).

Move cleanup into a pruneDerivatives helper that swallows storage errors, and
wrap the resize/host step so decoder/storage failures surface as a categorized
TikTokPublishException instead of an uncategorized error.
2026-06-26 13:08:11 -03:00
Paulo Castellano
0829814d8b test(tiktok): cover download-failure path and assert TikTok-specific optimization
Adds a test for the resize download-failure branch, and tightens the resize
mocks to assert optimization is requested for Platform::TikTok specifically.
2026-06-26 12:42:39 -03:00
Paulo Castellano
13e4d88b71 test(tiktok): cover MediaItem dimensions and platform max-width helpers
Direct unit tests for the new MediaItem::width()/height() accessors and
MediaOptimizer::maxWidthForPlatform(), plus drop a redundant inline comment.
2026-06-26 12:31:46 -03:00
Paulo Castellano
c5fca040a9 fix(tiktok): resize oversized photos before PULL_FROM_URL
TikTok photo posts hand the platform our media URLs and let it pull the
bytes, so they never went through MediaOptimizer like the upload-based
publishers do. An image wider than 1080px reached TikTok untouched and was
rejected with picture_size_check_failed ("Image dimensions exceed limits").

Photos within spec still pass through unchanged. Oversized ones (or images
with unknown dimensions) are downloaded, resized to TikTok's 1080px spec,
hosted on the public disk, and that URL is handed to TikTok instead. The
derivative is pruned once TikTok has pulled it.

Reuses the same hosted-derivative pattern Instagram/Threads already use for
aspect-ratio cropping; the TikTok max_width config was previously dead code.
2026-06-26 12:23:56 -03:00
Paulo Castellano
98f13d38b5
Merge pull request #119 from trypostit/chore/release-email-unsubscribe
chore(release): add unsubscribe footer to the customer email template
2026-06-26 11:10:22 -03:00
Paulo Castellano
0ee5d807a2 chore(release): add unsubscribe footer to the customer email template 2026-06-26 11:02:06 -03:00
Paulo Castellano
1d31fa6d9b
Merge pull request #118 from trypostit/docs/readme-supported-platforms
docs(readme): add Telegram & Discord, fix doc links and org slug
2026-06-26 10:50:29 -03:00
Paulo Castellano
8d14ad2268 docs(readme): reposition around product value, fix self-host and mcp links
Drops the Buffer/price-comparison framing for product-led positioning (one
calendar, AI copilot, MCP, native publishing) plus an own-your-stack section,
a hero image, and brand colors. Fixes the self-host link to
/self-hosting/overview and the MCP link to /ai/introduction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:46:38 -03:00
Paulo Castellano
34e289b18f docs(readme): update logo link to relative path for improved portability 2026-06-26 10:40:35 -03:00
Paulo Castellano
b7cd1cf22b docs(readme): update logo link, enhance feature descriptions, and improve layout
- Changed logo source to a direct GitHub link for better accessibility.
- Revised feature descriptions to highlight the AI copilot and multi-platform capabilities.
- Improved layout and organization of the README for clarity and visual appeal.
2026-06-26 10:39:34 -03:00
Paulo Castellano
7bc7e8b82e docs(readme): fix platforms, doc links, org slug; drop stale sections
- add Telegram and Discord to Supported Platforms (3x4 grid)
- point documentation links at docs.trypost.it (was trypost.it/docs)
- correct the GitHub org slug trypost-it -> trypostit (broken links)
- drop the "TryPost 1.0 is here" announcement and the non-existent Roadmap link
2026-06-26 10:30:12 -03:00
Paulo Castellano
caf4369b4d
Merge pull request #117 from trypostit/chore/release-v1.0.5-artifacts
chore(release): add v1.0.5 changelog + customer email artifacts
2026-06-26 10:24:05 -03:00
Paulo Castellano
bcb3038b96 chore(release): drop cloud-onboarding section, point CTA to GitHub Discussions 2026-06-26 10:19:54 -03:00
Paulo Castellano
84bf8c99b6 chore(release): polish v1.0.5 email (copywriting + humanizer pass) 2026-06-26 09:50:40 -03:00
Paulo Castellano
72af500a14 chore(release): expand v1.0.5 email to cover v1.0.3-v1.0.5 (prior emails unsent) 2026-06-26 09:42:31 -03:00
Paulo Castellano
cc8fb497e0 chore(release): add v1.0.5 changelog + customer email artifacts 2026-06-26 09:37:41 -03:00
Paulo Castellano
05faa49c30
Merge pull request #116 from trypostit/fix/x-video-upload-oom
fix(social): stop chunked video uploads from exhausting memory
2026-06-25 21:54:56 -03:00
Paulo Castellano
a285c0c1fe revert(youtube): drop the unverified chunked-upload guard
YouTube uploads through the Google API client's resumable handler, not our
HTTP client, so the leak couldn't be reproduced for it and the nextChunk loop
can't be exercised by a test without making the Google client injectable.
Keeping this PR to changes that are both reproduced and covered by a test
(X and LinkedIn); YouTube is tracked separately.
2026-06-25 21:46:35 -03:00
Paulo Castellano
317788eb37 fix(social): apply chunked-upload memory fix to LinkedIn and YouTube
The same leak audited in the X publisher exists wherever a video is streamed
through a per-chunk request loop. Reproduced with the reported 49MB file:
LinkedIn's plain Http::withToken() chunk loop grew to ~146MB (and it allows
videos up to 5GB). Centralize the fix as freeChunkMemory() on the shared
trait and call it (after unset) in the X, LinkedIn and YouTube loops so memory
stays flat.

Audited and left alone: TikTok and Instagram pull from a video_url (no local
upload); Facebook and Bluesky stream the whole file in a single request.

Adds a LinkedIn multi-chunk video test.
2026-06-25 21:38:52 -03:00
Paulo Castellano
5b065ce121 fix(x): stop chunked video upload from exhausting memory
The APPEND loop sent each 1MB segment without releasing it: the chunk plus
its multipart copies and the Guzzle/retry object graph were kept alive by
reference cycles the GC doesn't reclaim mid-loop, so memory grew ~4MB per
segment. A 49MB video peaked at ~242MB locally and crossed the 512MB limit
in production. Freeing the segment and forcing cycle collection each
iteration keeps memory flat (~50MB regardless of video size).

Also harden socialHttp()'s retry predicate: it read $exception->response
directly, which fatals on exceptions without a response (e.g. a
ConnectionException during a long upload), masking the real error.
2026-06-25 21:26:15 -03:00
Paulo Castellano
973fc604a2
Merge pull request #115 from trypostit/feat/onboarding-goal
feat(onboarding): add goal step after persona
2026-06-25 21:10:12 -03:00
Paulo Castellano
f8f6d263ad refactor(onboarding): send goals to PostHog exactly like persona
Mirror the persona handling: identify the user with a single `goals` person
property instead of an array plus a boolean per goal. Drops the goalProperties
helper.
2026-06-25 21:06:27 -03:00
Paulo Castellano
2525d834b0 test(onboarding): cover the full flow; drop goal exclusivity rule
- add an end-to-end walk (account gate -> persona -> goals -> connect ->
  Stripe) and a self-hosted bypass test, plus the connect no-workspace and
  just-exploring-saved cases
- drop the just_exploring exclusivity: the backend now saves any valid goal
  combination (the front-end still clears siblings as a UX nicety), removing
  the withValidator rule, the unused Goal::isExclusive(), and the now-unused
  goals_exclusive copy
2026-06-25 21:01:46 -03:00
Paulo Castellano
afc4c7a80b feat(onboarding): add goal step after persona
After picking who they are, users now pick what they want to achieve with
TryPost. A multi-select goal step (12 options + an exclusive "just exploring"
and "something else") sits between the persona step and connect, mirroring the
persona screen's style.

The goals persist to a json column on users and are mirrored to PostHog on
identify (onboarding_goals array plus a boolean per goal), so campaigns can be
cross-tabbed against the intent they actually attracted. connect now requires
both a persona and at least one goal; persona store advances to the goal step.

Options are grounded in TryPost's real capabilities (publishing, AI content,
brand voice, automation via API/MCP, collaboration, analytics) and copy is
localized in en/es/pt-BR.
2026-06-25 20:49:04 -03:00
Paulo Castellano
93208d4bb2
Merge pull request #112 from trypostit/feat/linkedin-pdf-carousel
feat(linkedin): PDF documents, media-inferred post format, and unified connection
2026-06-25 20:03:20 -03:00
Paulo Castellano
27151bd925 fix(pinterest,youtube): default video cover and gate empty YouTube posts
Pinterest video pins failed with "provide cover_image_url ... or
cover_image_key_frame_time" because no cover was sent. Default to
cover_image_key_frame_time 0 (first frame) when the user provides no
cover_image_url — verified against Pinterest's OpenAPI spec (integer
seconds, minimum 0, falls back to the last frame past the duration).

YouTube Shorts derive their required title from the post content, so the
publisher rejects an empty post — but the editor still let it be scheduled.
Gate it in compliance: a youtube_short with empty content is now flagged
(requires_text) so it can't be scheduled, instead of failing at publish.
2026-06-25 19:59:51 -03:00
Paulo Castellano
f0e2907ab5 fix(accounts,media): address PR review findings
Connect flow:
- surface OAuth failures: useOAuthPopup forwards {success, message}; the
  accounts grid toasts the error instead of silently reloading
- detect a blocked popup and toast a "allow popups" message
- PopupCallback only promises auto-close when it has an opener, otherwise
  tells the user to close it; adds role=status/aria-live and aria-hidden icons
- drop the dead LinkedInPage connect-route entry and the unsent access_token
  field on the Facebook page-select type

Media/gallery:
- delete dead code: MediaDropzone.vue and useDragAndDrop.ts (no references)
- GalleryBrowser toasts per-file upload failures instead of swallowing them;
  cards are always clickable now that videos open in the lightbox
- formatDurationMs rounds the sub-second branch and tolerates undefined
- advertise PDF in the gallery's accepted-formats hint

i18n: popup_blocked, manual_close (accounts) and upload.failed (assets) in all locales.
2026-06-25 19:06:43 -03:00
Paulo Castellano
91b353c569 refactor(date): consolidate duration formatting into three date helpers
Five hand-rolled formatDuration variants (two of them byte-identical
across the automations pages) collapse into formatClock, formatDurationWords
and formatDurationMs on the date helper, all backed by dayjs.duration.

Migrates the video badge, AI loading timer, media validation params, and
the automation metrics/invocations tables onto them. Output is unchanged
except the loading timer's minutes are no longer zero-padded (5:30, not 05:30).
2026-06-25 18:42:05 -03:00
Paulo Castellano
1206448f17 feat(media): preview PDFs in the lightbox dialog
ImagePreviewDialog now renders documents via an <iframe> (the browser's
native PDF viewer) alongside images and videos. The post view, gallery
browser, and editor composer no longer divert PDFs to a new tab or strip
them from the collection — a PDF opens in the dialog and navigates
(prev/next) together with the other media.
2026-06-25 18:29:10 -03:00
Paulo Castellano
e6826d499f docs: add media-types single-source-of-truth rule to CLAUDE.md
Document that media-type detection lives in App\Enums\Media\Type (backend)
and resources/js/lib/mediaType.ts (frontend), the three types
(image/video/document), the type→mime→extension precedence, the MediaType
union on every media interface, and acceptAttribute() for upload accepts.
2026-06-25 18:29:10 -03:00
Paulo Castellano
a8bed34d17 refactor(media): centralize frontend media-type detection in lib/mediaType
Mirror the backend App\Enums\Media\Type on the frontend so "is this an
image/video/PDF?" has a single definition instead of being hand-written
(type === 'x' || mime_type startsWith ...) across a dozen files.

- add lib/mediaType.ts: MediaType union, classify(), fromMimeType(),
  fromExtension(), isImage/isVideo/isDocument/isGif, acceptAttribute()
- route every MediaItem detection and every File-upload validator through it
- fix MediaDropzone rendering a typeless image item (AI/Unsplash/Giphy)
  as a broken <video> via the img-vs-video v-else
- collapse getMediaIncompatibilityReason onto the shared
  getMediaValidationWarning core (canSchedule / variant selection unchanged)
- tighten MediaItem/AssetMedia/SavedMedia/PickedMedia .type to MediaType
- replace GalleryBrowser's hardcoded accept list with acceptAttribute()
2026-06-25 18:19:58 -03:00
Paulo Castellano
49c77406a0 fix(linkedin): enforce carousel cap and fail loudly on processing timeout
- publishCarousel now caps images at the platform max so the API/MCP paths
  can't bypass the 10-image limit the UI enforces
- waitForProcessing throws on timeout instead of posting against an asset
  still being processed; poll attempts/interval extracted to overridable
  protected methods for testability
- scope EnsureHasWorkspace to connect entry points + disconnect only;
  session-driven OAuth callbacks/selects stay ungated so a missing current
  workspace can't HTML-redirect the popup instead of closing it cleanly
2026-06-25 15:34:03 -03:00
Paulo Castellano
12a1baa757 refactor(social): address PR review follow-ups
- DeleteWorkspace now reassigns affected users to another workspace they
  belong to (instead of nulling current_workspace_id), so deleting the
  current workspace while owning others no longer bounces to "create".
- Remove the dead LINKEDIN_PAGE_CLIENT_REDIRECT env (the unified flow uses
  a single callback; config no longer reads a page redirect).
- useOAuthPopup uses the Wayfinder connect helpers per platform instead of
  hardcoding /connect/{platform}.
- Unify the Inertia\Response alias in Mastodon/Bluesky controllers to
  InertiaResponse, matching the other connect controllers.
- Add tests: workspace reassignment on delete, and independent refresh of
  a linkedin row vs its linkedin-page sibling.
2026-06-25 15:01:23 -03:00
Paulo Castellano
dba6346226 refactor(auth): split workspace gate into EnsureHasWorkspace middleware
EnsureAccountReady bundled a subscription gate (redirects to onboarding,
SaaS only) with a workspace gate (redirects to workspace creation). The
connect routes can't sit behind it because connecting/disconnecting
happens during onboarding, before a subscription exists.

Split the workspace gate into a standalone EnsureHasWorkspace middleware:

- EnsureAccountReady is now subscription-only.
- EnsureHasWorkspace redirects to workspace creation when there is no
  current workspace, in both SaaS and self-hosted modes.
- The social connect group gains EnsureHasWorkspace; the main app group
  gains it alongside EnsureAccountReady (listed after it, so the
  subscription gate still runs first — no custom middleware priority).
- The repeated `if (! $workspace) redirect()` guard is removed from the
  connect/store/authorize/disconnect/index/toggle handlers, and their
  return types are tightened (no more dangling RedirectResponse).

LinkedIn connect's no-workspace path changes from a popup callback to the
same redirect as the other platforms.
2026-06-25 14:30:15 -03:00
Paulo Castellano
cd2798fd8d refactor(social): use native Inertia for all connect popup flows
Replace the per-platform native form POST + manual CSRF + JSON/Blade
popup callback with a single Inertia mechanism:

- popupCallback() renders the accounts/PopupCallback Inertia page (notifies
  the opener + closes the popup) for both the GET OAuth callbacks and the
  selection submits. Drops the auth.social-callback Blade view, the
  expectsJson JSON branch, and useHttp/useSocialConnect on the frontend.
- Selection/credential pages (LinkedIn, Facebook, Instagram, Bluesky,
  Mastodon) use Inertia useForm: automatic CSRF + native validation errors.
- Unify the three selection screens on one row + View/Choose layout; the
  LinkedIn company tag now uses a building icon.
- Bluesky auth failures throw ValidationException (422 for XHR, redirect
  back with errors otherwise).

Controller tests updated from assertViewIs/assertViewHas to assertInertia.
2026-06-25 13:54:16 -03:00
Paulo Castellano
10b8579079 fix(linkedin): cap image count at 10 to match platform standard
LinkedIn was the only multi-image platform allowing 20 images; align
maxMediaCount/maxImages/maxFiles to 10, consistent with Facebook,
Instagram and Threads. The AI generation cap (MAX_GENERATED_IMAGES)
was already 10 and is unaffected.
2026-06-25 11:45:11 -03:00