Centralize the per-platform mb_substr truncation that every publisher was
repeating into MediaItem::altTextFor(Platform), delete each publisher's
private altFor() helper, and clarify the Platform::altTextMaxLength()
docblock so it doesn't imply Instagram's documented 1000-char cap is a
guess. Add the assertions review flagged as missing: LinkedInPage/
InstagramFacebook alt-text caps, non-string and literal-"0" alt_text
normalization, altTextFor() truncation/unsupported-platform behavior,
Mastodon's no-description-part case, and the public API's accept/reject
path for media.*.meta.alt_text.
Use an explicit `!== null` guard in the Facebook, Instagram, Threads, and
Discord publishers instead of a truthy `if ($alt = ...)`, matching the
Pinterest/X/Mastodon style. The truthy form dropped a literal "0" alt; the
explicit check treats every non-null description uniformly. Also add the
missing PHPDoc to Platform::supportsAltText().
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).
- CropsImageForAspectRatio: drop the per-publisher path-prefix argument; crops
from both platforms go to a single `social-crops/` directory (the folder has
no functional meaning — the platform fetches by URL and nothing reads it back;
UUID filenames prevent collisions).
- Close the IG/FB test asymmetry surfaced in review: Instagram single-image crop
now runs across 1:1/4:5/16:9 (dataset) and has a crop-download-failure test;
Facebook gains an explicit 'original' bypass test. Both platforms now cover the
same matrix (crop per ratio + uploaded-URL is the crop, original/no-meta
bypass, multi-image, download-failure exception).
Follow-up tracked in #83 (orphaned crops are never pruned from storage).
Facebook feed posts now have the same aspect ratio selector as Instagram
(1:1, 4:5, 16:9, Original), letting users control how their image is cropped
before publishing instead of leaving it to Facebook's auto-crop.
The Facebook Graph API has no crop/aspect-ratio parameter for organic Page
photo posts (image crops are ads-only) — Facebook displays the bytes it
receives at their native ratio. So, exactly like Instagram, the crop is done
server-side before upload.
- Extract the Instagram crop logic into a shared CropsImageForAspectRatio
concern (download -> crop -> store -> URL), with a per-publisher
cropFailureException hook. InstagramPublisher refactored to use it
(behaviour identical; existing tests unchanged).
- FacebookPublisher reads meta.aspect_ratio and crops single- and multi-image
feed posts (facebook-crops/ prefix). Reels/Stories are unaffected.
- FacebookSettings: aspect ratio picker on the Feed variant (default Original
-> no crop). ScheduleTab wires meta; FacebookPreview reflects the chosen
ratio. Adds posts.form.facebook.aspect.* in en/es/pt-BR.
Closes#69
Facebook Stories do not work reliably with images via the Graph API.
Align validation and publisher with video-only (like Reel), while
Instagram Story continues to accept image or video.
Co-authored-by: Cursor <cursoragent@cursor.com>
Multi-image posts were published as text-only because Laravel sent
attached_media as JSON; Facebook accepts message but drops attachments.
Use asForm() for all FacebookPublisher Graph API calls.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Add separate canDelete predicate in Index.vue that includes Failed —
the previous EDITABLE_STATUSES gating hid the delete button for
failed posts even though the backend allows deleting them.
- Edit.vue Echo handler navigates to /show when an in-page real-time
status update transitions the post into a read-only state, instead
of leaving the user on a stuck readonly editor.
- FacebookPublisher: stop using empty() for content checks (treats
literal "0" as empty) — compare explicitly against null and "".
- Update terminal-state error messages in API + MCP update tool to
reflect the broadened guard (no longer Published-only). Adjust the
matching test assertion.
Production incident: a customer's Facebook Page post failed with 'The post
is empty. Please enter a message to share.' (error code 197) and ended up
with a contradictory DB state (status=published + error_message=set).
Three independent bugs were uncovered:
A. FacebookPublisher sends 'message'/'description' as null when the user
posts media without text. Graph API requires the key be omitted, not
null. Fixed in publishSingleImagePost, publishMultiImagePost,
publishVideoPost, publishReel.
B. markAsPublished/markAsFailed leak stale fields across transitions
(a published row could retain error_message from a prior failure,
vice-versa). Both transitions now explicitly clear the opposite
side's fields.
C. status='failed' was editable in the UI and the backend, so users
were re-clicking Publish, generating duplicate failure emails and
the contradictory state from bug B. The frontend isReadOnly check
and the UpdatePost backend guard now treat Published/PartiallyPublished/
Failed/Publishing as terminal. To retry, the user duplicates the post.
11 new tests guarantee these can't regress silently: FB payload shape
per content type, PostPlatform field-clearing on transitions, and the
terminal-status block at the controller level.
Five small fixes scoped to the publishReel method:
1. Replace generic \Exception on media download failure with a typed
FacebookPublishException(ServerError). The generic exception was
landing in the \Throwable catch in PublishToSocialPlatform with
category 'unknown', defeating the whole point of the social
exception hierarchy.
2. Replace handleApiError($startResponse) with a direct
FacebookPublishException throw when video_id/upload_url are missing.
The previous code passed a successful HTTP response into a method
built for error responses — fromApiResponse would fall into the
default arm and surface 'An unknown Facebook error occurred.'
ironically reintroducing the same bad UX we just spent the day
fixing.
3. Drop the four redundant Log::error calls before handleApiError.
FacebookPublishException::fromApiResponse already pulls the FB
error code/subcode/message into platformErrorCode + userMessage,
and the downstream catch in PublishToSocialPlatform::handle logs
the exception anyway (Nightwatch picks that up). Same pattern as
the X cleanup in PR #29.
4. Stream the upload body via fopen() resource instead of
file_get_contents(). Eliminates loading the whole video into memory
for large reels.
5. Replace \@unlink with unlink + Log::warning. Surfaces temp-file
cleanup failures instead of silently leaking files.
Tests:
- Strengthened the missing-upload_url test to assert the exception
message and class.
- Added a typed-exception test for the media-download failure path
(would have caught the regression where we used a generic
\Exception).
- Full suite green: 1505 passed, 2 skipped, 0 failed.
Previous attempt at the hosted-file flow (`file_url` in JSON body) hit
the rupload.facebook.com validator with HTTP 400:
'HeaderValuePredicate: Header Offset not convertable to unsigned long'
Facebook's rupload endpoint requires the `Offset` and `file_size` headers
regardless of whether the upload is local or hosted-file. The docs
describe the hosted-file path without them, but in practice rupload
rejects requests that lack them.
Switching to the well-documented local-file flow:
- Download the media to a temp file via Http sink (already a pattern
used in XPublisher for media downloads).
- POST raw bytes to upload_url with three headers:
- Authorization: OAuth {token}
- Offset: 0
- file_size: {actual bytes}
- Use mime_type from the media item as the Content-Type.
- Always cleanup the temp file in a finally block.
Tests updated to fake the media-download GET (returning bytes that the
publisher then re-uploads) and to assert on the Offset/file_size/
Authorization headers being present and correct. The existing
"cleans up temp files after reel upload" test now actually exercises
its assertion since this code path creates temp files again.
Full suite: 1504 passed, 2 skipped, 0 failed.
Production was failing every Facebook reel publish with the cryptic
'Video Upload Is Missing' / error_subcode 1363130. Root cause: our
'transfer' phase was making up its own API contract that doesn't exist.
The Reels publishing API (per Meta docs) is a 3-step flow:
1. start → POST /{page_id}/video_reels {upload_phase=start}
returns {video_id, upload_url}
2. transfer → POST {upload_url} on rupload.facebook.com
Header: Authorization: OAuth {token}
Body (JSON): {"file_url": "https://..."}
3. finish → POST /{page_id}/video_reels {upload_phase=finish, ...}
Our code was doing:
2 (broken) → POST /{video_id} on graph endpoint
Body: {video_file_chunk: '<some URL>', access_token: ...}
`video_file_chunk` is not a real parameter; the graph endpoint accepted
the request (returning 200) but nothing was actually uploaded, so the
finish phase reported 'Video was not uploaded'.
Changes:
- Capture upload_url from the start response and use it for transfer.
- POST to that upload_url with file_url in JSON body and
'Authorization: OAuth ...' header (the format docs.facebook.com
documents for the hosted-file flow).
- Bail with handleApiError if the start response is missing video_id
or upload_url so we don't silently no-op like before.
Tests:
- Existing 'can publish reel' test updated to include upload_url in the
start mock and assert the transfer call actually POSTs to rupload
with file_url + OAuth header (would have caught this bug before
shipping).
- New 'fails reel publish when start does not return upload_url' test
for the safety bail-out.
- Pre-existing 'cleans up temp files after reel upload' test updated
to match the new mock pattern.
Full suite green: 1504 passed, 2 skipped, 0 failed.
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
EmojiPicker (full Unicode set, search, categories, recently-used,
light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
content_types map) so the user can switch Feed/Reel/Story etc. and have
it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
types); replaces inline maps across 5 components, fixes
instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
(was 429ing legitimate OAuth retries) and rely on the default limiter
- Fix UpdatePostRequest missing content_type, synced, meta fields
(content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
— X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
- Refactor WorkspacePolicy to use pivot role instead of workspace.user_id
- Add manageBilling policy (owner only) to BillingController
- Fix ApiKeyController authorization (view → manageTeam for store/destroy)
- Fix WorkspaceInviteController using workspace.user_id for owner checks
- Fix WorkspaceController settings is_owner using workspace.user_id
- Create PostAction enum for UpdatePost/PostController action strings
- Create ApiToken\Status enum
- Add User::SUBSCRIPTION_NAME constant, replace all hardcoded 'default'
- Convert wantsEmailFor to accept NotificationType enum
- Convert all $data[] to data_get() across publishers, controllers, jobs
- Fix SocialLoginController callback missing try/catch
- Fix SocialController::toggleActive missing workspace null check
- Fix UpdatePost NPE on meta merge when postPlatform not found
- Remove HTML5 required attributes from form inputs
- Convert function declarations to arrow functions in Vue components
- Replace hardcoded URLs with Wayfinder route helpers
- Replace new Date() with dayjs
- Add 16 new test files covering policies, authorization, publishing
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.