Commit graph

521 commits

Author SHA1 Message Date
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
Paulo Castellano
6f96d67dbc fix(social): distinguish platform-down from token-expired
When a provider's API was down (5xx, timeout, DNS), the hourly
RefreshSocialToken job and daily VerifyWorkspaceConnections job were
treating it as "token revoked" and emailing the user to reconnect.
Bluesky going offline triggered false-positive disconnect notifications
because Bluesky access tokens are short-lived (2h) so every hourly
refresh failed during the outage.

- New PlatformUnavailableException: API unreachable / 5xx, transient.
  TokenExpiredException stays for 4xx (token is provably bad).
- New TokenRefreshClient: normalizes failure semantics for OAuth
  refresh HTTP calls across all providers. Takes a Platform enum so
  typos fail at compile time and the user-facing label comes from
  one source.
- ConnectionVerifier: all 8 refresh*Token methods route through the
  new client. Hardcoded OAuth URLs (LinkedIn, YouTube) and Bluesky's
  default PDS host moved into config/trypost.php alongside the
  existing per-platform entries.
- RefreshSocialToken job: PlatformUnavailableException → log warning
  and stop. Do NOT markAsTokenExpired, do NOT notify the user. Next
  scheduled tick retries.
- VerifyWorkspaceConnections job: PlatformUnavailableException from
  the inner refresh propagates and is treated as a transient skip.
2026-05-19 08:16:43 -03:00
Sebastian Albert
3018c0353f
Change storage method to use public disk for assets 2026-05-19 09:31:40 +02:00
Sebastian Albert
78bee705ff
Change storage to public disk for media uploads 2026-05-19 09:30:49 +02:00
Sebastian Albert
15cbe9616f
Change reactions column to nullable with default null 2026-05-19 08:28:07 +02:00
Sebastian Albert
f7566c34ea
Change media column to nullable with default null 2026-05-19 08:19:40 +02:00
Paulo Castellano
a0861a0b92
Merge pull request #45 from trypostit/fix/assets-chunked-upload
fix(assets): always chunk uploads in GalleryBrowser
2026-05-18 20:04:44 -03:00
Paulo Castellano
f5e8dd2730 fix(assets): always chunk uploads in GalleryBrowser
The /assets page (and the picker dialog that reuses GalleryBrowser)
posted files in a single multipart request, so anything past nginx /
php-fpm's body limit failed with 413. The chunked endpoint and util
already existed but were only wired into an orphan composable.

- GalleryBrowser now always uploads via uploadChunked
- Drop the orphan useMediaManager composable
- Drop shouldUseChunkedUpload (no longer referenced)
2026-05-18 20:03:52 -03:00
Paulo Castellano
bdf5a8abde
Merge pull request #24 from andrefrd/feat/docker-dev-environment
feat(docker): single-command Docker stack for local development
2026-05-17 20:03:37 -03:00
Paulo Castellano
8bdc566a3c
Merge branch 'main' into feat/docker-dev-environment 2026-05-17 17:39:25 -03:00
Paulo Castellano
02776436cd
Merge pull request #42 from trypostit/feature/mcp-media-upload
feat(mcp): media upload via signed-URL flow
2026-05-15 17:10:16 -03:00
Paulo Castellano
7fac3aaf0f feat(media): rate-limit upload endpoint to 10 req/min/IP 2026-05-15 17:09:40 -03:00
Paulo Castellano
9845d15db3 refactor(media): MediaUploadResource + ws→workspace_id + cleanup 2026-05-15 16:48:09 -03:00
Paulo Castellano
98b3a9ffb7 test(mcp): end-to-end media upload flow 2026-05-15 16:37:40 -03:00
Paulo Castellano
41a704dc6a fix(mcp): use morph alias for mediable_type to match production storage 2026-05-15 16:34:25 -03:00
Paulo Castellano
dd1056a413 feat(mcp): AttachMediaFromUploadTool attaches uploaded Media by token 2026-05-15 16:29:05 -03:00
Paulo Castellano
7341852f5c refactor(mcp): move upload config from trypost.mcp to ai.mcp 2026-05-15 16:25:26 -03:00
Paulo Castellano
6b40edce55 refactor(mcp): extract MCP upload caps to trypost.mcp.upload config 2026-05-15 16:24:14 -03:00
Paulo Castellano
7971571dc3 feat(mcp): RequestMediaUploadTool issues signed upload URLs 2026-05-15 16:19:03 -03:00
Paulo Castellano
d50348562b refactor(media): atomic upload_token via transaction and tighten test assertions 2026-05-15 16:16:42 -03:00
Paulo Castellano
b17026f3a1 feat(media): signed POST upload endpoint for MCP flow 2026-05-15 16:10:21 -03:00
Paulo Castellano
5408ca7e27 style(media): add void return on migration closures, drop noop ->after 2026-05-15 16:06:54 -03:00