Commit graph

32 commits

Author SHA1 Message Date
Paulo Castellano
f74053b118 Extract alt-text truncation onto MediaItem and close test gaps
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.
2026-07-11 10:34:57 -03:00
Paulo Castellano
0e93d7e5e3 Normalize alt-text null checks across publishers
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().
2026-07-11 10:02:50 -03:00
Paulo Castellano
2c81416bd0 Publish per-image alt text to Facebook photos 2026-07-11 09:13:05 -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
1b096f9ede Unify crop storage folder and make IG/FB crop tests symmetric
- 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).
2026-06-05 18:30:17 -03:00
Paulo Castellano
e25b320ad2 Add aspect ratio selection for Facebook posts
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
2026-06-05 18:01:45 -03:00
Paulo Castellano
8dfa664fa6 fix(facebook): restrict Page Stories to video-only
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>
2026-05-21 21:21:50 -03:00
Paulo Castellano
eed1019301 fix(facebook): send Graph API requests as form-urlencoded
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>
2026-05-21 21:07:03 -03:00
Paulo Castellano
e4f8833608 fix(posts): allow deleting Failed posts + misc terminal-state polish
- 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.
2026-05-19 14:54:04 -03:00
Paulo Castellano
3f6032c152 fix(facebook): empty-message rejection + state consistency + no re-publish on terminal
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.
2026-05-19 12:47:18 -03:00
Paulo Castellano
ee18b489e6 refactor(facebook): cleanup publishReel after review
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.
2026-05-12 20:43:32 -03:00
Paulo Castellano
1d5d6ec063 fix(facebook): switch reel transfer to binary upload with Offset/file_size headers
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.
2026-05-12 20:35:54 -03:00
Paulo Castellano
c379c4c14f fix(facebook): use upload_url + file_url for reel transfer phase
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.
2026-05-12 20:32:20 -03:00
Paulo Castellano
f4fd8a2aab refactor: centralize social API base URLs into the configuration file to improve maintainability and environment flexibility 2026-05-02 13:49:20 -03:00
Paulo Castellano
dafdd5da43 feat: media gallery picker, custom emoji picker, preview tabs, real platform logos
- 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
2026-05-01 14:53:49 -03:00
Paulo Castellano
0f6ae9a4e6 feat: add PostCommentCreated broadcast event 2026-04-15 20:11:36 -03:00
Paulo Castellano
7aaaf4df4f fix: revert Facebook Reel/Story to URL pull (video_file_chunk as URL) — binary upload was breaking the transfer phase 2026-04-01 15:07:02 -03:00
Paulo Castellano
e034c0b572 feat: publishing hardening — content sanitization, validation, scopes, stuck recovery
- Add ContentSanitizer: strips HTML, converts bold/underline to Unicode for LinkedIn
- Add backend content length validation in all 11 publishers via HasSocialHttpClient trait
- Add scope verification before publishing — checks required scopes per platform
- Add RecoverStuckPosts command (every 30min) — recovers posts stuck in publishing > 1h
- Add requiredPublishScopes() to Platform enum
- Update SocialAccount factory with correct scopes per platform state
2026-04-01 11:25:25 -03:00
Paulo Castellano
c48c774e23 feat: publishing engine improvements — rate limit retry, inline token refresh, per-platform queues, proactive refresh
- Add HasSocialHttpClient trait with 429 rate limit retry (3 attempts, 5s delay)
- Integrate trait into all 10 publishers (YouTube uses Google SDK)
- Add inline token refresh retry in PublishToSocialPlatform job
- Add per-platform Horizon queues via Platform::queue() and Platform::allQueues()
- Add RefreshExpiringTokens hourly command for proactive token refresh
- Fix token leaks: redact response bodies in all Log::error calls
- Fix token leaks: remove $response->body() from exception messages
- Fix ConnectionVerifier: redact all refresh error logs
- Fix null checks on API response IDs (Instagram, Threads, Pinterest, Facebook)
- Fix PublishPost::failed() to mark post as failed
- Fix StoreChunkedMediaRequest: validate max 1GB total size
- Fix scheduled_at validation: string → date
- Fix StoreMediaRequest: images max 10MB, videos max 1GB, only MP4 video
2026-04-01 10:51:53 -03:00
Paulo Castellano
ce46a4d984 fix: close Facebook streams, check download responses, X stream upload 2026-04-01 08:59:47 -03:00
Paulo Castellano
45dd3f4bde fix: file handle leaks, return types, Facebook reel/story binary upload, null checks, cleanup 2026-03-31 22:41:26 -03:00
Paulo Castellano
0cf84e4424 chore: remove verbose Log::info, add GD memory check, redact tokens in exception context 2026-03-31 22:04:12 -03:00
Paulo Castellano
6c2a354594 refactor: remove unused context param from handleApiError across all publishers 2026-03-31 21:43:30 -03:00
Paulo Castellano
2320c9ae63 refactor: TikTok, YouTube, Facebook publishers use platform exceptions 2026-03-31 20:52:32 -03:00
Paulo Castellano
9f3b8e547a fix: overhaul social publishing — validation, uploads, token refresh
- 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
2026-03-31 19:25:19 -03:00
Paulo Castellano
74c6442728 refactor: code review fixes — policies, enums, data_get, tests
- 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
2026-03-31 00:40:18 -03:00
Paulo Castellano
56b8c92e72 refactor: settings redesign, Spanish translations, language system, strict_types
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.
2026-03-30 00:20:43 -03:00
Paulo Castellano
7f3fea991a fix: handle nullable content across all social publishers 2026-03-29 17:04:01 -03:00
Paulo Castellano
af32361e89 chore: Update and add various project dependencies. 2026-01-18 16:46:27 -03:00
Paulo Castellano
d538537706 feat: Implement user onboarding, subscription management, and refactor social integrations with new UI components and mail templates. 2026-01-17 12:36:49 -03:00
Paulo Castellano
7c00c3387e feat: Implement user onboarding, subscription management, and refactor social integrations with new UI components and mail templates. 2026-01-16 23:46:30 -03:00
Paulo Castellano
7c80d717b1 chore: first commit 2026-01-15 14:24:39 -03:00