Skip to content

fix(facebook): empty-message rejection + state consistency + no re-publish on terminal#41

Merged
paulocastellano merged 15 commits into
mainfrom
fix/facebook-empty-message
May 19, 2026
Merged

fix(facebook): empty-message rejection + state consistency + no re-publish on terminal#41
paulocastellano merged 15 commits into
mainfrom
fix/facebook-empty-message

Conversation

@paulocastellano
Copy link
Copy Markdown
Contributor

Production incident

A customer's Facebook Page post failed with 'The post is empty. Please enter a message to share.' (FB Graph API error code 197), then ended up with a contradictory DB row: `status=published` + `platform_post_id=set` + `error_message='The post is empty...'`.

DB evidence on the failed PostPlatform:
```json
{
"category": "unknown",
"platform_error_code": "197",
"failed_at": "2026-05-15T13:34:24+00:00",
"content_length": 0,
"media_count": 2
}
```

Investigation surfaced three independent bugs that conspired to produce the customer experience.

Bug A — `FacebookPublisher` sends `message: null` to Graph API

When the user has media without text, `$content` is null. The 4 publish paths (`publishSingleImagePost`, `publishMultiImagePost`, `publishVideoPost`, `publishReel`) all included `'message' => $content` (or `'description' => $content`) in the payload regardless. Facebook expects the key to be omitted, not null.

Fix: conditionally include the key only when content is non-empty.

Bug B — `PostPlatform` state transitions leak stale fields

`markAsPublished` did not clear `error_message`/`error_context` from a prior failed attempt. `markAsFailed` did not clear `platform_post_id`/`platform_url` from a prior published attempt. This caused the contradictory `status=published + error_message=set` row.

Fix: each transition now explicitly nulls the opposite side's fields.

Bug C — failed posts are still re-publishable

`Edit.vue::isReadOnly` did not include `'failed'`, so the UI re-enabled the Publish button after a failure. The customer (or anyone) could re-click and:

  • Generate duplicate "failed to publish" emails
  • Trigger a second job that, if successful, set publish fields but left the old error_message (Bug B)

Fix:

  • Frontend: `'failed'` added to `isReadOnly`
  • Backend: `UpdatePost::execute` rejects any mutation when status is `Published`, `PartiallyPublished`, `Failed`, or `Publishing` via a new `PostAction::Finalized` case. Defense-in-depth — frontend lock alone is not enough.

To retry a failed post, the user duplicates it (existing flow).

Tests (11 new — regression guarantee)

  • `FacebookPublisherTest` (4): each publish path asserts the key (`message` or `description`) is absent from the Graph API payload when content is null
  • `PostPlatformTest` (2): `markAsPublished` clears error fields; `markAsFailed` clears publish fields
  • `PostControllerTest` (4): re-publish blocked for each terminal status; `Bus::assertNotDispatched` confirms no duplicate job
  • Plus updates to 2 existing edit-redirect tests

If anyone removes the empty-message guard, the field-clearing, or the terminal block in the future, the build fails before merge.

Test plan

  • Full suite: 1552 passing, 0 failures
  • Pint clean
  • Manual: post FB Page with media + no text → submit → FB succeeds (no 'post is empty' error)
  • Manual: post fails for any reason → Publish button is disabled → can only Duplicate

Out of scope

  • Notification dedupe (if the customer already got a failure email, no follow-up success email is sent — that's an existing behavior, can be revisited)

…blish 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.
@paulocastellano paulocastellano force-pushed the fix/facebook-empty-message branch from 665cf12 to 3f6032c Compare May 19, 2026 15:56
paulocastellano and others added 14 commits May 19, 2026 12:59
The earlier rename of UpdatePost's terminal short-circuit from
AlreadyPublished → Finalized was applied to UpdatePostTool and
Api/PostController but missed PublishPostTool. Result: calling
publish-post-tool against a post in Published / PartiallyPublished /
Failed / Publishing returned the unchanged post wrapped as a success
response — the MCP client thought it had republished.

The check now accepts both action enums so the existing string
behavior (clear error) is preserved, and a Pest dataset covers all
four terminal statuses.
UpdatePost::execute used to return AlreadyPublished for the Published
short-circuit. This PR widened the short-circuit to four terminal
statuses and consolidated them under PostAction::Finalized — so the
old enum case stopped being emitted, and every caller already had a
defensive in_array([AlreadyPublished, Finalized], ...).

Audit before removal: nothing emits AlreadyPublished anymore (only
UpdatePost::execute returns Actions, and it returns Finalized for
the whole terminal set), no test references the case, and no string
'already_published' exists elsewhere in app/resources/tests/lang.

- Drop the enum case
- Simplify the three in_array checks to a direct === Finalized
- Delete the dead App/PostController branch that flashed the old
  cannot_edit_published message (its successor branch with
  cannot_edit_finalized stays). The old i18n key is left in lang/
  for now — orphan but harmless, can ressuscitate if a similar
  flash is added back.
…ed JSONs

- Remove cannot_edit_published from en/es/pt-BR posts.php — its sole
  caller (App/PostController) was deleted in the previous commit
  along with PostAction::AlreadyPublished.
- Add lang/php_*.json to .gitignore. They are build artifacts emitted
  by laravel-vue-i18n/vite from the lang/{locale}/*.php sources and
  shouldn't have been tracked (regenerated on every npm run build /
  composer dev). Untracking now also means the orphan key disappears
  from the compiled bundles on the next build without a manual edit.
UpdatePost::execute returns Finalized for Published, PartiallyPublished,
Failed, and Publishing — the four "terminal" states the PR introduced.
The existing tests for UpdatePostTool (MCP) and Api/PostController only
exercised the Published path. Convert both to a Pest dataset over all
four statuses so a future regression that lets one state slip through
the check fails loudly.

Same shape already used for PublishPostTool and App/PostController.
- PostController@edit was redirecting Failed→show while show was
  redirecting Failed→edit, producing ERR_TOO_MANY_REDIRECTS. Failed
  posts now render in show.
- New universal `hasContentOrMedia` rule in Edit.vue blocks publishing
  when both text and media are empty (closes the hole where empty posts
  could reach the publish button).
- Unified `PLATFORM_VARIANTS` to include Facebook, Instagram and
  LinkedIn variants. togglePlatform snaps to a compatible variant when
  reselecting a platform whose current content_type is incompatible
  with the attached media (fixes the case where Reel+image left the
  tile permanently blocked).
- platformIssues suppresses the issue on deselected tiles when a
  compatible variant exists, so the tile remains clickable and the
  snap can recover state.
- Use ContentType enum in place of string literals.
Introduce resources/js/types/post.ts mirroring App\Enums\Post\Status
and App\Enums\PostPlatform\Status. Replace inline string comparisons
across Edit.vue, Show.vue, PostEditorHeader.vue and ScheduleTab.vue.
- Drop 'failed' from EDITABLE_STATUSES in Index.vue and Calendar.vue
  so failed posts link directly to /show instead of /edit (which now
  redirects).
- Replace remaining 'scheduled'/'publishing' literals in
  PostEditorHeader.vue with the PostStatus enum.
- Include Failed in PostEditorHeader's READONLY_STATUSES to match
  Edit.vue.
- Split PLATFORM_VARIANTS by Platform key (Platform.LinkedInPage and
  Platform.InstagramFacebook were missing; page content types were
  grouped under the wrong personal-LinkedIn key).
- 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.
The backend edit() controller already redirects posts in terminal
states to /show; Inertia follows that redirect on partial reload.
…ance

Pulls ~200 lines of media/platform/length compliance and tooltip
derivation into composables/usePostCompliance.ts. Edit.vue is now
focused on state, save logic and event handlers; PLATFORM_VARIANTS
and the per-content-type media-rule check are exposed from the
composable for togglePlatform.
…helper

Pull the variant-snap branch out of togglePlatform into a named
helper with guard clauses. togglePlatform now reads top-down:
lock check → deselect short-circuit → snap → select.
…mePopover

- Introduced a new translation key for future date selection prompts in English, Spanish, and Portuguese.
- Implemented logic to disable the confirm button and display a warning message when a past date is selected in the PickTimePopover component.
- Updated date formatting utility to handle UTC dates for HTML datetime-local inputs.
Parse datetime-local values in the user timezone before converting to UTC,
matching formatUtcForDateTimeLocalInput. Fix ESLint import order in usePostCompliance.

Co-authored-by: Cursor <cursoragent@cursor.com>
@paulocastellano paulocastellano merged commit ac1a362 into main May 19, 2026
2 checks passed
@paulocastellano paulocastellano deleted the fix/facebook-empty-message branch May 19, 2026 20:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant