Commit graph

543 commits

Author SHA1 Message Date
Paulo Castellano
da0d5f2a42 fix(posthog): simplify user identification and account usage sync
Refactor the user identification process by removing unnecessary type casting for user ID and account ID. This change enhances code readability and maintains functionality for syncing account usage.
2026-05-19 19:20:56 -03:00
Paulo Castellano
ccd78e3c8d fix(posthog): harden usage sync after deletes and workspace removal
Use Account::postsCountCacheKey everywhere, avoid findOrFail when syncing
post deletes, dispatch SyncAccountUsage after DeleteWorkspace when enabled,
and add coverage for the new paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 19:01:53 -03:00
Paulo Castellano
a659a9eadf
Merge branch 'main' into feat/posthog-onboarding-properties 2026-05-19 17:00:34 -03:00
Paulo Castellano
ac1a362f2a
Merge pull request #41 from trypostit/fix/facebook-empty-message
fix(facebook): empty-message rejection + state consistency + no re-publish on terminal
2026-05-19 17:00:16 -03:00
Paulo Castellano
35051350c6 refactor(posts): encode scheduled_at for API via date helper
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>
2026-05-19 16:42:42 -03:00
Paulo Castellano
df35d1b671 feat(posts): add validation message for past date selection in PickTimePopover
- 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.
2026-05-19 16:26:40 -03:00
Paulo Castellano
1d61904bc8 refactor(posts): collapse TikTok/Pinterest meta rules into PLATFORM_META_RULES map 2026-05-19 15:36:51 -03:00
Paulo Castellano
3580d2eaf6 refactor(posts): flatten togglePlatform with snapToCompatibleVariant 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.
2026-05-19 15:15:13 -03:00
Paulo Castellano
2584257ee6 refactor(posts): extract Edit.vue validation logic into usePostCompliance
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.
2026-05-19 15:11:58 -03:00
Paulo Castellano
cad89a0a97 revert(posts): drop redundant router.visit in Edit.vue Echo handler
The backend edit() controller already redirects posts in terminal
states to /show; Inertia follows that redirect on partial reload.
2026-05-19 14:57:38 -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
31db72638b fix(posts): align Index/Calendar routing with Failed→show redirect
- 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).
2026-05-19 14:38:05 -03:00
Paulo Castellano
2902506f35 refactor(posts): replace status string literals with PostStatus enum
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.
2026-05-19 14:27:14 -03:00
Paulo Castellano
99d1770c6c fix(post): edit redirect loop + frontend validations
- 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.
2026-05-19 14:18:13 -03:00
Paulo Castellano
0c955e885f test(post): cover all 4 terminal statuses on MCP update + API update
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.
2026-05-19 13:19:01 -03:00
Paulo Castellano
d8f836a569 chore(i18n): drop orphan cannot_edit_published key + gitignore compiled 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.
2026-05-19 13:12:13 -03:00
Paulo Castellano
edec58af81 refactor(post): remove now-dead PostAction::AlreadyPublished
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.
2026-05-19 13:06:13 -03:00
Paulo Castellano
27f27b6683 fix(mcp): publish-post-tool blocks terminal states, not only Published
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.
2026-05-19 12:59:40 -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
19f3b02941
Merge pull request #53 from trypostit/fix/digitalocean-spaces-env-naming
feat(storage): DigitalOcean Spaces disk + env documentation
2026-05-19 12:39:24 -03:00
Paulo Castellano
91334104a0 feat(storage): DigitalOcean Spaces disk + env documentation
Adds 'spaces' to config/filesystems.php so self-hosters can switch
FILESYSTEM_DISK=spaces without bolting onto the generic s3 disk. Env
vars follow the existing R2/AWS convention (SPACES_ACCESS_KEY_ID /
SPACES_SECRET_ACCESS_KEY), and use_path_style_endpoint is omitted so
the SDK defaults to virtual-hosted style — the canonical request shape
for DO Spaces.

Also documents the five env vars in .env.example. Docs in the
trypost-docs repo describe the matching FILESYSTEM_DISK=spaces stanza.
2026-05-19 12:38:29 -03:00
Paulo Castellano
eae702e620
Merge pull request #47 from albertvisuals/main
Fix: JSON columns with default('[]') fail on MySQL 8
2026-05-19 12:26:05 -03:00
Paulo Castellano
c5209aeb0e
Merge branch 'main' into main 2026-05-19 12:25:54 -03:00
Paulo Castellano
50315ac241
Merge pull request #52 from trypostit/fix/profile-destroy-deletes-shared-account
fix(profile): member delete must not destroy the shared account
2026-05-19 12:25:35 -03:00
Sebastian Albert
5e3f1676c9
Fix indentation in ProfileController.php 2026-05-19 17:25:19 +02:00
Paulo Castellano
1660187067 fix(profile): member delete must not destroy the shared account (closes #50)
Members joining via workspace invite share the owner's account_id.
ProfileController::destroy was unconditionally calling $account->delete()
in every profile-deletion path, so any member could wipe the whole
organization (cascade: workspaces, posts, social accounts, signatures,
labels) just by clicking Delete on their own profile.

Gate the account/subscription teardown behind isAccountOwner(). For
members the path now only detaches them from workspaces and deletes
the user row — owner's data is untouched.

Tested in both SELF_HOSTED=true and false.
2026-05-19 12:20:49 -03:00
Sebastian Albert
b76d3b64a7
Fix formatting issue in ProfileController.php 2026-05-19 17:20:03 +02:00
Sebastian Albert
94d64c989b
Simplify account deletion condition in ProfileController 2026-05-19 17:19:37 +02:00
Paulo Castellano
48bf94e137
Merge pull request #51 from trypostit/feat/registration-enabled-flag
feat(auth): self-hosted registration gate + admin seeder
2026-05-19 12:06:59 -03:00
Paulo Castellano
e4bc6f1d5c test(auth): cover remaining surfaces of the registration gate
- POST /register with ?invite query and no prior session passes the gate.
- UserSeeder seeds the admin + workspace on an empty DB and skips when a
  user already exists.
- Login page exposes selfHosted prop in both modes (proves the flag the
  frontend uses to hide the "Sign up" link reaches the client).
2026-05-19 12:03:41 -03:00
Paulo Castellano
74bd88d3d6 feat(auth): self-hosted registration gate + admin seeder (closes #46)
Self-hosted installs (SELF_HOSTED=true, the default) now close /register
to the public. Workspace invites still work — the AcceptInvite page
links into /register with ?invite={id}, the middleware persists that
into the session, and POST /register passes through.

- EnsureRegistrationEnabled middleware gates GET/POST /register.
  Accepts ?invite=… (URL) or pending_invite_id (session) as the pass.
- RegisteredUserController::store clears the marker after signup.
- AcceptInvite.vue passes invite.id in the register link's query string.
- Login.vue hides the "Sign up" link when self_hosted.
- UserSeeder bootstraps a single admin (admin@trypost.it / password).
  Idempotent; not wired into DatabaseSeeder — operator runs
  `php artisan db:seed --class=UserSeeder` per the install docs.
- Tests cover both flag values for every changed surface.

Docs PR: see trypost-docs self-hosting/installation.mdx step 3.
2026-05-19 11:45:16 -03:00
Sebastian Albert
9bc46996e4
Refactor storage method for media upload 2026-05-19 16:42:25 +02:00
Sebastian Albert
7765640fdd
Refactor media storage method to use default disk 2026-05-19 16:41:40 +02:00
Sebastian Albert
16d69e4338
Refactor storage method for asset uploads 2026-05-19 16:37:05 +02:00
Sebastian Albert
c7309627ab
Refactor account deletion condition in ProfileController 2026-05-19 16:18:50 +02:00
Paulo Castellano
726831c511
Merge branch 'main' into main 2026-05-19 10:26:01 -03:00
Paulo Castellano
e2caefec6e
Merge pull request #49 from trypostit/fix/platform-unavailable-vs-token-expired
fix(social): distinguish platform-down from token-expired
2026-05-19 10:17:58 -03:00
Paulo Castellano
2509b6ee26 test(social): plug remaining gaps around retry-reschedule behavior
Audit found three behaviors with no explicit assertion:

- Job is dispatched with a 10-minute delay (would silently regress if
  the duration changed). Uses Carbon::setTestNow + Bus::assertDispatched
  inspecting \$job->delay.
- error_context.last_attempt_at is recorded at the moment of failure.
- updatePostStatus does NOT finalize the parent Post while any of its
  platforms is in Retrying — covers the central invariant of the
  feature (the post must stay Publishing until every platform lands
  in Published or Failed).
- A platform currently in Retrying transitions to Published when the
  next attempt succeeds — proves the loop terminates.
2026-05-19 10:14:49 -03:00
Paulo Castellano
d336f79059 feat(social): reschedule publish on PlatformUnavailable instead of failing
Before: a scheduled post hitting a platform outage was marked Failed —
user had to manually retry. Now the job reschedules itself for 10
minutes later and the PostPlatform shows status "Retrying". Loops
indefinitely until the platform accepts the post.

- Adds PostPlatformStatus::Retrying (existing string column, no migration)
- PublishToSocialPlatform: PlatformUnavailable catch now calls
  rescheduleForRetry() which (a) updates the row to Retrying with
  retry_count + next_attempt_at in error_context, and (b) dispatches
  itself with a 10-minute delay. updatePostStatus() naturally leaves
  the parent Post in Publishing because Retrying is neither Published
  nor Failed.
- Same treatment for the retry-refresh edge case (publisher throws
  TokenExpired, refresh subsequently fails with PlatformUnavailable).
- i18n + frontend status config updated (en, pt-BR, es) for both
  posts.status.retrying and posts.edit.status.retrying.
- Tests: 3 new tests covering the dispatched job, the edge case path,
  and retry_count increment across attempts.
2026-05-19 10:03:30 -03:00
Paulo Castellano
6c6078fa6a refactor(social): drop redundant \$account->refresh() after CV refresh
Every refresh*Token method inside ConnectionVerifier already finishes
with \$account->update(...) (mutates the model) + \$account->refresh()
(reloads from DB to pick up sibling updates like LinkedInTokenSynchronizer).
The lock-contention branch in refreshToken() also calls \$account->refresh()
before returning.

So a second \$account->refresh() in the caller was always a redundant
SELECT — no scenario where it actually pulled a different value than
what CV already left in memory. Removed from all 15 call sites, plus
fixed the duplicated "Mastodon tokens don't expire" comment.
2026-05-19 09:52:49 -03:00
Paulo Castellano
a42962c0ca fix(social): publish retry honors PlatformUnavailable too
Edge case from the prior commits: if the publisher throws TokenExpired
(401 path), PublishToSocialPlatform attempts refreshAccountToken() to
recover. That internally goes through ConnectionVerifier::verify, which
can now raise PlatformUnavailable (5xx). The old catch (\Throwable)
swallowed it but the loop still fell through to markAsTokenExpired —
meaning a transient platform outage during a retry could still flip the
account to expired.

Adds an explicit PlatformUnavailable catch in the retry block: marks
the post failed with category platform_unavailable and breaks before
touching the account status.
2026-05-19 09:47:12 -03:00
Paulo Castellano
04975020e4 test(social): tests pull OAuth URLs from config
Same rule applied to production code in earlier commits now applies to
tests: Http::fake patterns and assertions read from
config('trypost.platforms.*.oauth_api' / '.api' / '.default_service')
instead of hardcoded strings. Hardcoded URLs in tests drift silently
when the config changes.

Also documents the rule in CLAUDE.md under "External Service URLs" so
new code (and tests) start in the right place — only the host comes
from config, path/RPC segments stay inline next to the call.
2026-05-19 09:37:16 -03:00
Paulo Castellano
f975171a9a test(social): close coverage gaps for TokenRedactor and 429 handling
- TokenRedactorTest (7 unit tests): all four regex patterns, multiple
  secrets in one body, null input, and the no-op pass-through case.
  Guards against silent regression of the redaction regexes (which
  previously drifted across three duplicated copies).
- ConnectionVerifierTest: HTTP 429 during refresh raises
  PlatformUnavailableException, not TokenExpiredException. Locks in
  the rate-limit-as-transient behavior added when consolidating the
  refresh logic.
2026-05-19 09:34:04 -03:00
Paulo Castellano
123996bbc0 refactor(social): TokenRedactor::redact accepts nullable
Lets the call sites drop the explicit null check ternary. The redactor
itself owns the null handling — one less conditional at every caller.
2026-05-19 09:25:01 -03:00
Paulo Castellano
cf80e1fcae refactor(social): single source of truth for token redaction
Same shape of bug as the refreshToken duplication: the regex that strips
access_token / Bearer headers from logged HTTP bodies existed in three
near-identical copies (HasSocialHttpClient trait, TokenRefreshClient,
SocialPublishException), drifting subtly — SocialPublishException was
missing the JSON "token" pattern.

Extracts a TokenRedactor::redact(string) helper and routes all callers
through it. Adding a new token format now means one regex in one file.
2026-05-19 09:21:43 -03:00
Paulo Castellano
fcb70b3599 refactor(social): single source of truth for token refresh
Every per-platform publisher and analytics class had its own private
refreshToken() implementation, all doing essentially the same thing as
ConnectionVerifier's per-platform refresh*Token methods. ~17 copies of
near-identical OAuth refresh logic across the codebase, which is how
the 5xx→TokenExpired bug got planted in the publish path even after we
fixed it on the hourly/daily jobs.

Consolidation:
- All publish/analytics paths call app(ConnectionVerifier::class)
  ->refreshToken($account) instead of their own implementation.
- 17 private refreshToken() methods deleted.
- refreshTokenWithLock helper removed from HasSocialHttpClient trait
  (its lock semantics are duplicated by ConnectionVerifier's per-
  account lock).
- ConnectionVerifier::refreshLinkedInToken passes $account->platform
  to TokenRefreshClient so the "LinkedIn" vs "LinkedIn Page" label
  in error messages is preserved.
- TokenRefreshClient now treats HTTP 429 the same as 5xx (raises
  PlatformUnavailableException) — replaces the retry-on-429 behavior
  that socialHttp() provided to the deleted refresh methods.

Net: -460 LOC. Single per-platform refresh implementation. Every fix
or new platform now lands in exactly one place.
2026-05-19 09:18:58 -03:00
Paulo Castellano
32e1f89adb fix(social): publish flow honors PlatformUnavailable too
The previous commits in this PR closed the loophole on the hourly /
daily token-refresh jobs. The same loophole remained on the publish
path: every per-platform publisher (LinkedIn, X, YouTube, TikTok,
Threads, Instagram, Pinterest, Bluesky and their Analytics siblings)
has its own refreshToken() called before publishing a scheduled post,
and all of those treated any non-2xx as TokenExpired — including 5xx.

Result before this commit: a Bluesky outage that coincided with a
scheduled publish would mark the account as expired and fail the post.

Changes:
- Route every refreshToken() in the 16 publisher / analytics classes
  through TokenRefreshClient::for(Platform::X)->send(...).
- TokenRefreshClient now also fills platformErrorCode from the HTTP
  status and pulls error_description / error.message from the JSON
  body, preserving the richer info LinkedIn / X / TikTok / Pinterest /
  Threads used to put on their TokenExpiredException.
- PublishToSocialPlatform catches PlatformUnavailableException
  explicitly: the post is marked failed (category: platform_unavailable,
  with http_status in error_context) but the account stays Connected.
  No retry inside this job — the scheduler reattempts the next run.

Test added: publish flow does NOT mark account expired when the
publisher throws PlatformUnavailable. Full suite: 1569 passing.
2026-05-19 09:01:02 -03:00
Paulo Castellano
090cc761dd test(social): smoke tests for analytics refactored in this PR
LinkedInPageAnalytics and MastodonAnalytics were the only two of the 11
files refactored to read OAuth host / default instance from config that
had zero test coverage. Adds smoke tests that assert the HTTP request
hits the configured URL, so a typo in the config key (e.g. linkedin.api
vs linkedin.oauth_api) would now fail loudly.
2026-05-19 08:42:02 -03:00
Paulo Castellano
1660084227 refactor(social): use config for OAuth hosts everywhere
Earlier in the PR the new configs (linkedin.oauth_api, youtube.oauth_api,
bluesky.default_service, mastodon.default_instance) were only read by
ConnectionVerifier. The same URLs were still hardcoded in the publishers,
analytics and the Bluesky auth controller — meaning a self-hosted user
setting BLUESKY_DEFAULT_SERVICE or MASTODON_DEFAULT_INSTANCE in env would
get split behavior: refresh/verify honor the override, publish/analytics
don't.

Routes all 10 remaining call sites through the same config values so the
overrides actually work end-to-end.
2026-05-19 08:34:35 -03:00
Paulo Castellano
d5e28e3d02 fix(social): move Mastodon default instance to config + cleanup
Review follow-ups:

- verifyMastodon was the last hardcoded host left after the PR moved
  LinkedIn/YouTube/Bluesky to config. Adds trypost.platforms.mastodon
  .default_instance (env MASTODON_DEFAULT_INSTANCE) and reads from it.
- refreshToken() docblock now declares @throws PlatformUnavailableException
  (the whole point of the PR was missing from its contract).
- Strip the new explanatory comments inside catch blocks and tests —
  rationale lives in the commit / PR, not inline. The two comments
  inside empty `catch (TokenExpiredException) {}` blocks stay because
  there the comment is the only thing telling the reader why the
  exception is swallowed.
2026-05-19 08:27:46 -03:00