Commit graph

934 commits

Author SHA1 Message Date
Paulo Castellano
56d7fa4033
Merge branch 'main' into feat/content_languages 2026-07-03 19:38:37 -03:00
Paulo Castellano
ec808bdea8 Fix the remaining reka trigger-width dropdowns for Tailwind v4
The workspace switcher menu, the label filter, and the templates category
popover still used the v3 bracket syntax w-[--reka-*-trigger-width], which
Tailwind v4 compiles to an invalid `width: --reka-...` so the floating content
no longer matched its trigger. Switch them to the v4 CSS-variable syntax
w-(--reka-*-trigger-width), matching the FontPicker/LanguagePicker fix.
2026-07-03 19:32:52 -03:00
Paulo Castellano
de4d5f3c68 Share a ContentLanguageOption type and close the review's test gaps
- Extract ContentLanguageOption into @/types and use it for availableContentLanguages
  across BrandForm, BrandTab, Brand, Create, and the LanguagePicker options, so the
  englishName field that drives search is visible to TypeScript instead of being
  dropped silently by the pass-through prop types.
- Add BrandAnalyzerTest: assert the language schema enum equals the full 15-language
  set (guards against it shrinking back to a hardcoded subset behind ::fake()) and
  that instructions() lists every code.
- Isolate the "LLM language wins" autofill test by declaring the page as `en` while
  the LLM returns `de`, so it actually proves mergeLlm precedence instead of both
  paths agreeing.
- Cover SetLocale's cookie side effect: the default locale cookie is set on an
  invalid/absent cookie and left untouched for a valid locale.
2026-07-03 19:28:28 -03:00
Paulo Castellano
5a96bb490a Make the language and font dropdowns match their trigger width
The PopoverContent used w-[--reka-popover-trigger-width], which Tailwind v4
compiles to the invalid `width: --reka-popover-trigger-width`, so the dropdown
collapsed to its content width. Use the v4 CSS-variable syntax
w-(--reka-popover-trigger-width) so it resolves to var(...) and spans the full
trigger width, matching the SearchableSelect component.
2026-07-03 19:01:16 -03:00
Paulo Castellano
c9209cb2a9 Replace the boolean isRtl() with a direction() accessor on ContentLanguage
isRtl() existed only to be mapped to an 'rtl'/'ltr' string in SetLocale, so the
boolean was the redundant concept. direction() returns the string the one caller
needs, which drops the null-safe-plus-nested-ternary from the middleware and lets
it resolve the language once with an explicit DEFAULT fallback.
2026-07-03 18:57:09 -03:00
Paulo Castellano
c7be1af124 Detect all 15 content languages on autofill, harden RTL/i18n, and cover the gaps with tests
- Brand-analyzer prompt now lists every supported language instead of only
  en/pt-BR/es, so onboarding autofill can detect the 12 added languages. The
  backtick-formatted list is built in BrandAnalyzer::instructions(), keeping the
  Blade clean and the enum free of prompt presentation.
- Translate the delete-confirmation keyword for el/ja/zh/ar (the four locales
  that still shipped the English "delete").
- Make the language and font comboboxes RTL-correct (logical ms-* instead of
  physical ml-*), and let the language combobox be searched by English name via
  a visually-hidden label (ContentLanguage::options() now exposes englishName).
- Correct the ContentLanguage class docblock: the enum is also the source of
  truth for the UI locales' text direction.

Tests: SetLocale middleware dir/RTL, isRtl and the full 15-language
englishName/label match arms, LLM language detection beyond en/es/pt-BR, and
store-path persistence of a non-default content language plus rejection of an
unsupported one.
2026-07-03 18:52:45 -03:00
Paulo Castellano
519175f9fd Translate the sidebar Beta badge and place it at the row end for RTL
Move the nav badge from the physical right-2 to the logical end-2 so it
sits at the end of the row in both directions (right in LTR, left in RTL).

Replace the hardcoded 'Beta' string with a global common.beta translation
key across all 15 locales.
2026-07-03 18:22:19 -03:00
Paulo Castellano
e17b86ac93 Make the content-language field a searchable combobox and reload on UI-language switch
Replace the plain content-language <Select> in the brand form with a
searchable LanguagePicker combobox (Popover + Command), matching the
FontPicker. i18n the combobox placeholder/search/empty strings across all
15 locales.

Switch the UI language via a full page reload instead of client-side dir
syncing, so the server-rendered <html dir> flips LTR<->RTL correctly
without a manual refresh.
2026-07-03 18:17:38 -03:00
Paulo Castellano
c59adb4871 Cover every supported language in ContentLanguage detection tests
Extend the homepage-language detection dataset to assert all 15 supported
languages resolve from their <html lang> subtag, and pin the primary-subtag
matching so a malformed tag ("english") no longer resolves via a two-letter
prefix.
2026-07-03 15:39:35 -03:00
Paulo Castellano
6797706921 Translate the interface into 12 languages
Add full lang/ translations for fr, de, it, nl, pl, el, ja, ko, zh, ru,
tr, and ar — all 23 base files each, with identical key trees to lang/en,
preserved :placeholders and plural forms, and native product terminology.
2026-07-03 15:38:40 -03:00
Paulo Castellano
85120ac8f3 Add UI localization infrastructure and RTL support for 12 new locales
Register the 12 additional languages (fr, de, it, nl, pl, el, ja, ko, zh,
ru, tr, ar) as available UI locales so the language switcher and the API
accept them, keeping the set in lockstep with the ContentLanguage enum.

- config/languages.php lists all 15 UI locales with their native names.
- ContentLanguage::isRtl() drives the document `dir`; SetLocale shares it
  to the Blade root and HandleInertiaRequests shares it to Inertia, and
  app.ts mirrors it on SPA navigations so RTL locales lay out correctly.
- dayjs imports the 12 new locales so dates localize instead of falling
  back to English.
- A LocalizationParityTest guards against key drift: every locale must
  ship every base translation file with exactly the keys of lang/en.
2026-07-03 15:38:40 -03:00
Paulo Castellano
f7ef13c116 Centralize supported content languages in a ContentLanguage enum
The new content-language options were hand-duplicated across request
validation, the UI picker, and homepage detection, while the brand
analyzer's structured-output enum and the AI image prompt's language
name still only knew about en/pt-BR/es. That left autofill unable to
detect the new languages and made image text fall back to English for
them.

Introduce App\Enums\Workspace\ContentLanguage as the single source of
truth and derive every site from it:

- Store/UpdateWorkspaceRequest validate against ContentLanguage::values()
- BrandAnalyzer's language enum uses ContentLanguage::values()
- AiImageClient::languageName() resolves via the enum's englishName()
- HomepageMetaExtractor detects through ContentLanguage::fromHtmlLang()
- BrandForm consumes availableContentLanguages from the backend, like
  availableFonts/availableImageStyles, instead of a hardcoded list

Also fix two labels: nl "Nederlandse" -> "Nederlands", zh -> "中文".
2026-07-03 14:12:25 -03:00
Paulo Castellano
665cc2204b
Merge pull request #171 from trypostit/fix/x-refresh-token-rotation
Fix social account disconnects: refresh tokens per platform's refresh model
2026-07-03 13:35:47 -03:00
Paulo Castellano
c8f875550a Replace generic token-TTL constant with per-platform Platform::defaultTokenTtlSeconds()
The single LONG_LIVED_TOKEN_TTL_SECONDS constant (Meta 60-day) plus a loose
inline 7200 for X made it unclear which networks each value applied to. Express
the fallback TTL as a per-platform match method instead, matching how the enum
already exposes every other per-network value, so the network->value mapping is
visible in one place: X 2h, Instagram/Threads 60d, everyone else null (they
always return expires_in). Behavior is unchanged.
2026-07-03 13:11:36 -03:00
Paulo Castellano
ba8068f517 Recover from a lost-rotation race on the post-refresh verify too
refreshThenVerify only guarded refreshToken(); the verify that followed was
outside the try, so the sub-commit window where a lock-skipped refresh reloads a
not-yet-persisted token surfaced as a 401 there and falsely disconnected a
still-usable single-use-refresh_token account (X/LinkedIn). The refresh and the
verify that follows it now share one recovery: on a TokenExpiredException,
reload and, if a concurrent refresh has since persisted a fresh access_token,
verify with it instead of giving up.
2026-07-03 11:39:48 -03:00
Paulo Castellano
3d3150bd20 Redact the Threads token-exchange failure logs
The connect flow logged the raw response body of a failed token exchange,
unlike the TokenRedactor discipline used everywhere else. A failure body
carries no token, but redacting keeps it consistent and defensive.
2026-07-03 11:26:48 -03:00
Paulo Castellano
83ba72ddc6 Stop Meta rate-limit errors from disconnecting Instagram/Threads tokens
TokenRefreshClient classified every non-5xx/429 refresh failure as a dead
token. Meta returns rate-limit (code 4/17) and transient (code 1/2) errors as
HTTP 4xx with type OAuthException, so a throttled proactive refresh was
disconnecting still-valid Instagram/Threads tokens — and the wider 24h/15-min
refresh cadence raised the odds of hitting it.

Classification now keys on error code 190 (the signal the publish exceptions
already use): only a genuine 190 disconnects; every other Meta 4xx is treated as
transient (PlatformUnavailable) and retried next cycle. The same over-broad
type-based check in verifyInstagram/verifyFacebook/verifyThreads is replaced
with the shared Meta\GraphError helper so verify and refresh agree.
2026-07-03 11:07:54 -03:00
Paulo Castellano
4d5ca6b274 Centralize the Meta long-lived token TTL and clarify the extension helper
The 60-day fallback used when Meta omits expires_in was duplicated as a bare
5184000 across the Instagram/Threads connect and refresh code; it now lives in
one place, Platform::LONG_LIVED_TOKEN_TTL_SECONDS. Also renames
Platform::extensionModelValues() to accessTokenExtendingPlatformValues() so the
name states what it returns without needing the extendsAccessTokenOnRefresh
docblock.
2026-07-03 11:07:54 -03:00
Paulo Castellano
545a779848 Never leave Instagram/Threads with a null token expiry
A null token_expires_at drops an account from every refresh path (the
cron's whereNotNull filter and the is_token_expired / is_token_expiring_soon
checks all treat null as "nothing to do"), so the token silently lapses.
Threads could persist null two ways: the long-lived exchange failing at
connect (kept the ~1h short-lived token) — now fails the connect instead;
and a refresh response omitting expires_in — now defaults to 60 days for
both Instagram and Threads, matching the X refresh convention.
2026-07-03 10:33:26 -03:00
Paulo Castellano
94d0156ec0 Give Instagram/Threads a wider proactive-refresh window
Extension-model tokens (Instagram/Threads) can't be refreshed once they
expire, so the shared 30-minute cron window left only a ~15-minute buffer
against queue backlog on the default queue — and a lapse forces a full
reconnect. Rotating platforms go through verify() and won't rotate a
still-valid token, so they keep the tight 30-minute window; extension
platforms now get a 24-hour lead via a per-model query.
2026-07-03 10:33:26 -03:00
Paulo Castellano
6037169aa9 Cover Threads token extension with an explicit test
The extend-while-valid path is shared by Instagram and Threads via
Platform::extendsAccessTokenOnRefresh(); add the Threads sibling of the
Instagram job test so both extension-model platforms are locked down.

Refs #126
2026-07-03 09:58:26 -03:00
Paulo Castellano
1bb67b7abb Keep Instagram/Threads tokens extended while still valid
Cold review caught a regression from the two previous commits. Instagram and
Threads use long-lived tokens refreshed by EXTENDING the access_token itself
(grant_type=ig_refresh_token / th_refresh_token) — they have no separate
refresh_token and CANNOT be refreshed once expired. The anti-over-rotation rule
("only refresh a token once it's actually expired") is right for rotating
single-use refresh_token platforms but wrong for these: it left IG/Threads
tokens to lapse, after which the extend call fails and the account disconnects
(~every 60 days).

Gate the anti-rotation on the platform's refresh model:
- Platform::extendsAccessTokenOnRefresh() — true for Instagram/Threads.
- SocialAccount::needsProactiveTokenRefresh() — expired for rotating platforms,
  OR expiring-soon for extension platforms (restores isTokenExpiringSoon).
- RefreshSocialToken extends (refreshToken) extension-model tokens while still
  valid, and verifies (access-token-first) rotating ones.
- All 23 publisher/analytics pre-checks now use needsProactiveTokenRefresh().

Tests: proactive job extends a still-valid Instagram token; a model test covers
the rotating-vs-extension branching; existing X/LinkedIn anti-rotation tests
are unchanged.

Refs #126
2026-07-03 09:18:45 -03:00
Paulo Castellano
3dd1804c8e Stop publishers/analytics from rotating still-valid tokens
Every publisher and analytics service proactively refreshed the token when
it was expired OR merely "expiring soon" (within 15 min), calling
refreshToken() directly. For X (and other single-use-refresh providers)
that rotated a perfectly valid access_token whenever an operation ran in the
token's final 15 minutes — the same needless rotation that breaks the
refresh_token chain and disconnects accounts.

Narrow every pre-check to refresh only when the token is actually expired. A
still-valid token is used as-is; if it expires mid-operation the existing
reactive retry (PublishToSocialPlatform) refreshes and retries.

- Drop `|| is_token_expiring_soon` from all 23 publisher/analytics pre-checks.
- Remove the now-unused `isTokenExpiringSoon` accessor (no references remain
  anywhere in the repo).
- The reactive retry path (AbstractLinkedInPublisher::retryWithRefresh) and
  the expired-token path are unchanged.

Refs #126
2026-07-03 08:30:58 -03:00
Paulo Castellano
2f4b974130 Fix X token chain breaking from over-rotation
X OAuth2 refresh tokens are single-use: each refresh rotates the pair and
invalidates the previous refresh_token, and reusing a rotated one kills the
whole family. Three things made this fragile and disconnected accounts far
more often than necessary:

- The proactive refresh job called refreshToken() directly, bypassing the
  access-token-first guard in verify() and rotating on every run.
- RefreshExpiringTokens used a 2h window on an hourly schedule — equal to the
  2h access-token lifetime — so every X account was rotated every hour even
  while its token was still valid.
- A single 4xx refresh failure disconnected the account without checking
  whether a concurrent refresh had already persisted a working token.

Changes:
- RefreshSocialToken now routes through verify() (access-token-first), so it
  only rotates when the access_token is actually invalid.
- Shrink the proactive window to 30m and run the command every 15m, so the
  window still covers the run interval but rotation happens near real expiry.
- verify() tolerates the lost-rotation race: on a 4xx refresh, reload and
  verify with a concurrently-refreshed token before marking TokenExpired.

Refs #126
2026-07-02 20:32:55 -03:00
Paulo Castellano
1284c46960
Merge pull request #146 from dantaspaulo/fix/workspace-content-language-locale
fix(workspace): default content_language to the app locale, not 'en'
2026-07-02 16:08:54 -03:00
Paulo Castellano
a4792300cc
Merge branch 'main' into fix/workspace-content-language-locale 2026-07-02 15:56:45 -03:00
Paulo Castellano
5ba6779c76
Merge pull request #144 from dantaspaulo/fix/media-services-null-key
fix(media): don't 500 when the Unsplash/Giphy key is set to null
2026-07-02 15:56:14 -03:00
Paulo Castellano
49e4f36f8d
Merge branch 'main' into fix/media-services-null-key 2026-07-02 15:34:45 -03:00
Paulo Castellano
b566dafbeb
Merge pull request #170 from trypostit/fix/begin-checkout-tracking
fix(tracking): fire begin_checkout when onboarding checkout starts
2026-07-02 09:25:20 -03:00
Paulo Castellano
974c076e86 fix(tracking): fire begin_checkout when onboarding checkout starts
trackBeginCheckout was defined but never called, so the begin_checkout
(GTM/dataLayer) and checkout.started (PostHog) events never fired. Wire
it into the onboarding Connect submit handler, before the redirect to
Stripe, and pass the workspace plan from the controller so the event
carries plan name + interval.
2026-07-02 09:21:31 -03:00
Paulo Sérgio Dantas
7dab106f06 fix(workspace): default content_language to the app locale, not 'en'
When a workspace is created without an explicit content_language,
`data_get($data, 'content_language')` returns null, array_filter drops the key,
and the column falls back to its DB default of 'en'. So AI content and
notifications came out in English even when the app was running in another
locale.

Default to `app()->getLocale()` instead, so a new workspace inherits the user's
current language. An explicit content_language still wins.
2026-07-01 13:49:48 -03:00
Paulo Sérgio Dantas
4e80fd54b7 fix(media): don't 500 when the Unsplash/Giphy key is set to null
`config('services.unsplash.access_key', '')` only falls back to '' when the key
is *absent* — if the env var is present but empty, config returns null, and
assigning null to the `string` property throws a TypeError, taking the whole
request down with a 500 during media search.

Cast to `(string)` so a null/empty key degrades gracefully to '' and the service
returns an empty result set (as it already does for a missing key) instead of
throwing.

Covered by a regression test for both services.
2026-07-01 12:16:15 -03:00
John Rallis
eb0ec6fb24 Add more content languages 2026-06-30 14:17:30 +03:00
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