Commit graph

247 commits

Author SHA1 Message Date
Paulo Castellano
b4f61be6ef
Welcome: pre-subscription funnel and member subscription-required screen (#243)
* Rename pre-subscription onboarding funnel to Welcome.

Move the ICP steps to /welcome, drop the social-connect checkout gate, hold unpaid members on a subscription-required screen, and keep legacy /onboarding URLs working until the post-subscription checklist lands.

Closes #237

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop legacy /onboarding ICP URL aliases.

Unfinished users re-enter Welcome via EnsureAccountReady on next login; /onboarding stays free for the post-subscription checklist.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Simplify Welcome PostHog event names.

Use welcome.persona/goals/referral and drop the unused checkout case — begin checkout stays on the frontend as checkout.started / begin_checkout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Slim welcome goal options to match the #204 set.

Drop team_collaboration, automate_api, and track_performance so the goals step stays at nine choices.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Split Welcome AI goals into TryPost AI and MCP assistants.

Rewrite ai_content for in-app generation and add use_mcp so Claude/ChatGPT/Cursor intent is captured separately across locales.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden Welcome goals gate and drop dead checkout UI.

Treat removed goal values as incomplete so mid-funnel users re-select, remove the unused canCheckout branch, and fix the pt-BR welcome progress label.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add password visibility toggle to the login form.

Match the register eye control so users can reveal their password while signing in.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Point the sidebar community link to Discord.

Replace the X stay-updated entry with Join Discord and the trypost.it/discord invite.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Rename the sidebar Discord link to Discord community.

Softer label that matches the other support nav items.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix broken Turkish Discord community translation.

An unescaped apostrophe left a parse error in lang/tr/sidebar.php.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 11:34:50 -03:00
Paulo Castellano
4d8353d758
MCP: workspace settings, viewer read access, and token access (#241)
* Add workspace MCP settings and token access controls.

Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring,
and workspace.token:mcp gating so assistants can connect without pulling
in welcome/onboarding from the parent epic.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Type MCP client config shapes instead of string checks.

Encode http/config-root on each advanced client and tighten primary
client ids so snippet generation does not branch on magic strings.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Polish MCP settings follow-ups from review.

Translate Ukrainian MCP copy, deep-link ChatGPT into connector
creation, drop an unused asset and revoke arg, and assert PATs are
rejected on the MCP endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden MCP connected clients, revoke scope, and OAuth consent.

List recoverable sessions with live refresh tokens, revoke only PATs,
throttle registration alone, and block viewers from authorizing MCP.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Simplify MCP OAuth route throttling to a single middleware group.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Allow workspace viewers read-only MCP access with web policy writes.

Mirror the web app: MCP connects on view + OAuth mcp:use, write tools
enforce createPost/update/delete/manageAccounts/manageTeam, and demotion
to Viewer keeps grants. Cover role denials, consent, and disconnect.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden MCP tool authz with shared workspace helpers.

Route ApiKey tools through AuthorizesMcpTool, fail closed on null user
or policy argument, and resolve the current workspace before mutating.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop redundant string casts on validated request data.

Enum::from and validated() fields are already strings, so the casts
add noise without changing behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Show only the current user's MCP connections in settings.

Match API keys privacy: list and disconnect your own OAuth clients,
not teammates' across the account.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop redundant is_string guard before UpdatePostTool find.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined.

* Refresh MCP connected clients with Inertia usePoll.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Bump laravel/mcp to 0.9.1 and add the TryPost server icon.

Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop no-op ReflectionClass import in TryPostServerTest.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 09:54:51 -03:00
Paulo Castellano
53a5a8bf22
Allow account owners to delete workspaces (#208)
* Allow owners and admins to delete workspaces from settings.

Expose a danger zone with name confirmation, sync Stripe quantity on SaaS, and skip billing constraints in self-hosted mode.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop redundant canDelete prop from workspace settings.

The settings page is already gated by update (owner/admin), which matches delete.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Extract workspace delete danger zone into DeleteWorkspace component.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Clarify workspace delete billing copy across locales.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Match workspace delete card to the delete-account settings pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden workspace and account deletion around shared members.

Enforce owner-only workspace creation, rehome stranded members to a personal account, warn about member access loss, and clarify the only-workspace SaaS exit paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden workspace delete: owner-only billing impact and safer member rehome.

Restrict delete to account owners, rehome stranded members transactionally with account-scoped fallbacks, and clean up the danger-zone UI/copy.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix workspace delete review findings.

Prune pending invites and media on delete, lock the account for the
last-workspace guard, fall back to account-owned workspaces for owners,
redirect self-hosted last deletes to create, cancel Stripe after local
cleanup, align personal-account trials, and gate Index create for owners.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Harden invite accept and account delete edge cases.

Stop invite accept from demoting existing roles, expire dead invites on
show, preserve flash by avoiding calendar bounces, move media file I/O
outside locked delete transactions, and finish account deletion even if
Stripe cancel fails.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Fix remaining invite redirect and media cleanup edge cases.

Distinguish already-accepted invites from gone workspaces, rehome
members removed from their last shared workspace, capture media paths
inside the delete lock, extract orphaned-file cleanup, and use Wayfinder
for the expired-invite home link.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Fix invite current-workspace and account-delete edge cases.

Switch invitees onto an invite-account workspace when accepting, prefer
same-account fallbacks when removing members, abort account deletion if
Stripe cancel fails, and clear avatar media on profile delete.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Fix Stripe-failure media leak and invite cross-account redirect.

Flush workspace media files before billing cancel can abort account
delete, and rehome stranded non-owners before picking an invite redirect
fallback so current workspace never points across accounts.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Never set cross-account current workspace on member rehome.

Keep RemoveMember and account-delete member fallbacks same-account
only, clarify the billing-failure flash that workspaces were already
removed, and assert storage deletion in media cleanup tests.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Sync Stripe workspace quantity when account delete billing fails.

After local workspaces are wiped, a stuck cancelNow must still drop
seat quantity so the subscription cannot keep billing the old count.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Prune account invites when owner delete wipes workspaces.

Pending and accepted invites are removed with the workspaces so a
Stripe cancel failure cannot leave unique email/account rows that block
re-invites to a gutted account.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Extract DeleteWorkspaceMedia to purge workspace media rows.

Call sites capture returned paths inside the lock and still flush
orphaned storage files after commit via DeleteOrphanedMediaFiles.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Redirect to calendar after deleting a workspace with a fallback.

When DeleteWorkspace already sets another current workspace, sending
the owner to the workspace picker is unnecessary — take them back into
the app instead.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Use Wayfinder for invite redirect and logo home links.

Replace hardcoded /invites/{id} and / hrefs in AcceptInvite with
show.url() and home() route helpers.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Use Wayfinder home() for AcceptInvite logo link.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Extract AcceptInvite title and description into computeds.

Keeps the expired/active copy logic out of the template and matches
the existing trans() pattern used elsewhere.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Fix lazy-loading crash when deleting a workspace.

isAccountOwner() no longer touches the account relation unless it is
already loaded, and delete/rehome queries eager-load account when they
need ownership checks under Model::shouldBeStrict().

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Avoid isAccountOwner during workspace delete fallback.

Compare against the already-loaded account owner_id so current-workspace
reassignment cannot touch the account relation under shouldBeStrict().

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Add tests for DeleteWorkspace functionality

Introduce comprehensive tests for the DeleteWorkspace action, covering scenarios such as deleting stranded members, handling multiple workspaces, restoring members with personal workspaces, and managing invites. Ensure that workspace media files are deleted and verify behavior when the last workspace is blocked by SaaS settings. This enhances the reliability of workspace deletion processes and ensures proper account management during deletions.

* Refactor member removal process to delete or restore stranded members

Updated the RemoveMember action to utilize the new DeleteOrRestoreStrandedMember class, which handles the deletion of stranded members or restoration to personal accounts. This change improves the management of user accounts when members are removed from workspaces, ensuring that non-owner members are properly handled based on their account status. Additionally, tests have been updated to reflect these changes, ensuring that the functionality works as intended.

* Enhance member removal and media management during account deletion

Updated the RemoveMember action to collect media paths for orphaned files when removing members. Integrated the DeleteOrphanedMediaFiles action to ensure that any media associated with deleted users is properly purged. Additionally, refactored the DeleteOrRestoreStrandedMember class to return media paths for cleanup, improving overall resource management during user account deletions. This change ensures that all orphaned media files are handled efficiently, maintaining system integrity.

* Enhance user account deletion process with force delete option

Updated the DeleteOrRestoreStrandedMember class to include a forceDelete parameter, allowing for immediate deletion of members and their associated personal accounts and workspaces. This change ensures that when an account is forcefully deleted, all remnants of the user's data are purged, improving data integrity and resource management. Additionally, updated related methods and tests to accommodate this new functionality, ensuring comprehensive coverage and correct behavior during account deletions.

* Extract shared delete/invite actions out of fat controllers.

Centralize workspace/account/user teardown and invite accept/decline so ProfileController and AcceptInviteController stay thin HTTP wrappers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden delete/invite invariants and replace invite string outcomes.

Block cross-account workspace listing/switching, cancel Stripe on owned accounts before purge, lock RemoveMember, fold owner fallback into ReassignCurrentWorkspace, and type invite results with an enum.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Polish delete/invite teardown APIs and cancel Stripe on empty accounts.

Extract DeleteEmptyOwnedAccounts, rename settle-after-invite, and expose
clearer stranded-member entry points so cancel never races the invite lock.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Finish stranded teardown craft: settle outside locks, clearer names.

Defer empty-account Stripe cancel until after the account lock, rename
stranded handling to SettleStrandedMember, and extract AccountsRequiringCancel.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden multi-account Stripe cancel order and typed stranded settlements.

Cancel member personals before the shared account, introduce CancelAccounts
and StrandedSettlement::flush so partial Stripe failures leave billing intact.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse strandedMemberOnSharedAccount across delete/invite feature tests.

Expand the Pest helper for shared workspaces and owner injection so
stranded-member fixtures stop being hand-rolled in every suite.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Lock the account row during owner account teardown.

Serialize DeleteAccount with DeleteWorkspace/RemoveMember so concurrent
stranded restores cannot move members off the account before force-delete.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop personal-account restore when leaving a shared account.

Invitees abandon their previous personal account on accept, and stranded
members are always deleted — matching the real product flow.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Close the account model and consolidate teardown actions.

Block invites to emails that already belong to a registered user — accounts
are closed (one user, one account), so members never own a personal account.
This removes the whole leftover/restore surface.

Consolidate: fold AccountsRequiringCancel/CancelAccounts into
CancelAccountSubscription, drop DeleteEmptyOwnedAccounts/DeleteOwnedAccount/
PurgeOwnedAccounts, and fold DeleteAccount into DeleteUser. 23 -> 15 new
action files.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove orphaned members.errors.already_member translation key.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Block invitees from creating a workspace on the invite shell.

A pending invitee could open workspaces/create (outside EnsureHasWorkspace)
and add a workspace (then billing) on their empty signup shell before accept.
Accept only tears down an empty shell, so this left an abandoned, billable
account. Deny create/store while an invite is pending — the invitee joins via
the invite instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Tighten stranded-member fixtures to the closed-account model.

Drop the member's empty signup shell in strandedMemberOnSharedAccount and the
billing-abort profile test so the setup matches what accept actually leaves
(member owns nothing). Remove the never-overridden attachOwner param.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Bind invite registration to the invited email.

The register form shows the invited email as read-only when an invite id is
present, and store() rejects a different email for a valid invite. Also fixes
a latent bug: EnsureRegistrationEnabled only read the invite id from the query
string, so the self-hosted invite registration POST always 404'd.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move register validation into RegisterRequest.

Inline $request->validate() and the invite-email check move into
App\Http\Requests\App\Auth\RegisterRequest (withValidator). Invite detection
no longer sniffs a /invites/ redirect string — it resolves the invite id
directly; the invite registration test now uses a real invite.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:00:38 -04:00
Paulo Castellano
b9194b9c7d Surface Pinterest board truncation in the web editors.
Share {boards, truncated} via ListPinterestBoards into Inertia and warn in the board picker when pagination stopped early, matching API/MCP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 23:02:40 -03:00
Paulo Castellano
d8e43bcfbb Unwrap Pinterest board lists for the web editors.
getBoards now returns {boards, truncated}; pass only the boards array into Inertia pinterestBoards so post and automation pickers keep receiving an array.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 22:54:20 -03:00
Paulo Castellano
e4779dfcdf Clamp media byte caps to upload limits and surface truncated board lists.
Align editor/API/MCP size ceilings with trypost.media hard caps, return truncated from Pinterest board pagination stop conditions, and rename the signed-upload claim key and rate limiter away from the MCP-only naming.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 22:49:04 -03:00
Paulo Castellano
0d4e5fd963 Restore media-rule parity and release failed MCP upload tokens.
Keep Instagram feed requiring media and Discord/Telegram accepting GIFs after centralization, skip the empty workspace rate-limit bucket, and clear the signed upload claim when persistence fails so retries work.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 22:37:57 -03:00
Paulo Castellano
c040ba4686 Authorize API social accounts via SocialAccountPolicy.
Replace repeated workspace_id checks with PostPolicy-style denyAsNotFound tenancy so cross-tenant lookups stay 404 without leaking existence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 22:09:35 -03:00
Paulo Castellano
b28b18ef72 Address PR review: stream MCP uploads and close listing gaps.
Stream signed uploads through addMediaFromPath, return per-type max_bytes, harden Pinterest/Discord listing errors and pagination, and keep frontend duration fallbacks when Inertia once-props have not synced.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 22:00:07 -03:00
Paulo Castellano
ff0bfa8a06 Expose Pinterest board IDs via MCP and REST API.
Agents need board_id to publish pins; list boards per connected account so create/update can set platforms[].meta.board_id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 21:47:51 -03:00
Paulo Castellano
06f83a6571 Track post creation origin via created_via.
Persist whether a post was created through web, MCP, API, or automation so we can attribute entry points without guessing from request context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 11:19:01 -03:00
Paulo Castellano
66dbf438ee style: use interpolated medias/{$filename} paths
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:21:52 -03:00
Paulo Castellano
2ba5ce2b3f refactor: pull chunked upload orchestration out of AssetController
Controller only authorizes and returns ChunkReceipt. Multipart vs local assemble lives in ChunkedAssetReceiver; JSON shape comes from MediaResource.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:18:10 -03:00
Paulo Castellano
08ce75d2ec refactor: harden chunked cloud uploads after full PR review
Fix real edge cases: enforce 5MiB non-final parts, track offsets for ordered/idempotent chunks, size from bytes received, delete orphaned R2 objects if Media create fails, and split the controller into clearer paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:16:39 -03:00
Paulo Castellano
d4be085642 refactor: clarify multipart is an object-storage fast path, not a disk limit
Local/public already used the assemble-then-store flow. Rename supports() so it is obvious every filesystem disk works; multipart only kicks in for S3-compatible remote disks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:07:11 -03:00
Paulo Castellano
cd79554cfb fix: multipart chunked video uploads directly to R2
The last chunk was reassembling the full file locally and pushing ~185MB to R2 in one request (53s+). Videos/PDFs now upload each chunk as an S3 multipart part so finalize only completes the upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:04:52 -03:00
Paulo Castellano
005d3c9dd6 fix: stream chunked video finalize to object storage
Unicode filename encoding was correct, but large videos still died on the last chunk: the whole file was loaded into memory and uploaded to R2 via Guzzle within PHP-FPM's 30s limit. Stream non-images with writeStream and lift the time limit on finalize.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 10:55:57 -03:00
Paulo Castellano
a78f5a96ae feat(onboarding): add "where you found us" referral-source step
Adds a single-select referral-source step between the goals and connect
steps of onboarding. The choice is stored on users.referral_source and
mirrored to PostHog, mirroring the existing persona and goals steps.

- ReferralSource enum (12 sources) + nullable users.referral_source column
- referralSource()/storeReferralSource() controller actions with the same
  self-hosted, subscribed, persona and goals guards as the sibling steps
- connect() now requires a referral source before rendering
- Single-select ReferralSource.vue page mirroring the goals step
- Localized across all 15 locales
2026-07-18 16:07:11 -03:00
Paulo Castellano
2c2ab69e68 Merge main + finalize AI brand-colors toggle
Resolves the AiPostWizard conflict and completes the feature:
- i18n parity: brand_colors_label + brand_colors_description in all 15 locales
  (was en/es/pt-BR only, which broke LocalizationParityTest).
- Reworked the two-button toggle into a Switch with an explanatory description
  (matches the settings Switch/card pattern).
- Only shown for templates that honor the flag: added appliesBrandVisuals() to
  the AiContentTemplate contract (ImageCard=true, tweet cards=false), exposed as
  applies_brand_visuals in the create-page DTO, and gated the toggle on it — so
  it no longer appears (as a no-op) for tweet-card styles.
- Tests: TemplateContractTest covers appliesBrandVisuals for all templates.
2026-07-18 15:12:26 -03:00
Paulo Castellano
2c52c90b6e fix(mobile): polish automations, dialogs, workspaces and post views
- automations: minimal back-only header on mobile for the workflow builder
  and detail tabs (extracted AutomationMobileBackHeader); open live
  automations on the metrics tab (drafts still open on workflow); full-width
  status filter + refresh on the invocations toolbar; use IconMenu2 for the
  mobile sidebar trigger
- dialogs: stack DialogFooter primary-on-top / cancel-at-bottom on mobile
- workspaces: bring the workspace picker cards into the neo-brutalist design
- posts: left-align the label filter content; wrap the post-view date/status
  header so a long status badge no longer squeezes the date; add hamburger
  clearance to the editor's mobile tab bar
2026-07-18 10:58:07 -03:00
Paulo Castellano
4a08913d70 fix(security): make SSRF private-network block configurable and guard the last user-URL fetches
Add config('trypost.security.allow_private_network') (env TRYPOST_ALLOW_PRIVATE_NETWORK, default off) so self-hosted operators can reach their own internal network; only the private-IP rejection is bypassed, scheme/host checks always apply. Add SafeHttpFetcher::guardedRequest() and route the last unguarded user-supplied-URL fetches through it: the Unsplash/Giphy asset import, the API/MCP attach-media-from-URL download, and the OAuth avatar download. Our-own-storage reads (media crop, Bluesky media) are intentionally left unguarded so internal storage keeps working when self-hosted.
2026-07-17 15:40:15 -03:00
Paulo Castellano
8648ed9720 feat(bluesky): add link preview cards for posts
Bluesky does not hydrate link cards server-side, so build the app.bsky.embed.external embed at publish time: detect the first URL, scrape its OpenGraph metadata, and re-upload the og:image as the card thumb. Works for web, API and MCP. Adds a posts/link-preview endpoint so the editor renders the card live. The thumb download is SSRF-guarded and does not follow redirects.
2026-07-17 14:32:40 -03:00
Paulo Castellano
f36881cb6a Fail loud on a missing coupon and block unpaid extra workspaces
Two hardening fixes for the paid first month:

- FirstMonthCheckoutDiscount throws when the paid first month is enabled
  but STRIPE_FIRST_MONTH_COUPON_ID is unset, instead of silently charging
  every new customer the full price with no discount.
- Guard workspace store() with the same active-subscription check create()
  already applies, so a direct POST can't bootstrap a second billable
  workspace and inflate checkout quantity past the fixed first-month coupon.
2026-07-09 15:37:25 -03:00
Paulo Castellano
3bc481ac07 Make LogoAttacher honor its swallow-all contract; drop the caller try/catch
LogoAttacher::attach promised in its docblock that any failure — including a
persistence error — is logged and swallowed so the caller need not handle it.
But the persistence block was try/finally with no catch, so a Throwable from
clearMediaCollection/addMediaFromPath escaped. Both call sites (store and
updateSettings) each wrapped the call in an identical try/catch + Log::warning
to compensate — a band-aid duplicated across the controller.

Fix it at the root: the persistence block now catches Throwable, logs it, and
returns false, matching the documented contract. Both controller call sites
collapse to a single attach() line, and the now-unused Log/Throwable imports
are dropped.

Adds LogoAttacherTest covering the success path, the swallowed persistence
failure, a failed fetch, and a rejected mime type.
2026-07-03 20:15:37 -03:00
Paulo Castellano
b7773849ec Save the autofilled site logo as the workspace logo on brand settings update
Brand autofill on the workspace settings page already captured the site logo
and rendered a preview beneath the URL, but the update flow never persisted it.
The store flow attached it via LogoAttacher; the update flow was missing all
three legs: the form field, the request rule, and the controller attach.

- BrandTab: add logo_url to the useForm payload so autofill can set it and the
  form submits it.
- UpdateWorkspaceRequest: validate logo_url (nullable url) — FormRequest strips
  any unvalidated key, so without a rule it was silently dropped.
- WorkspaceController::updateSettings: pull logo_url out of the validated data
  (it is not a column) and attach it through LogoAttacher, mirroring store.
2026-07-03 20:05:54 -03:00
Paulo Castellano
56d7fa4033
Merge branch 'main' into feat/content_languages 2026-07-03 19:38:37 -03:00
Paulo Castellano
f7ef13c116 Centralize supported content languages in a ContentLanguage enum
The new content-language options were hand-duplicated across request
validation, the UI picker, and homepage detection, while the brand
analyzer's structured-output enum and the AI image prompt's language
name still only knew about en/pt-BR/es. That left autofill unable to
detect the new languages and made image text fall back to English for
them.

Introduce App\Enums\Workspace\ContentLanguage as the single source of
truth and derive every site from it:

- Store/UpdateWorkspaceRequest validate against ContentLanguage::values()
- BrandAnalyzer's language enum uses ContentLanguage::values()
- AiImageClient::languageName() resolves via the enum's englishName()
- HomepageMetaExtractor detects through ContentLanguage::fromHtmlLang()
- BrandForm consumes availableContentLanguages from the backend, like
  availableFonts/availableImageStyles, instead of a hardcoded list

Also fix two labels: nl "Nederlandse" -> "Nederlands", zh -> "中文".
2026-07-03 14:12:25 -03:00
Paulo Castellano
c8f875550a Replace generic token-TTL constant with per-platform Platform::defaultTokenTtlSeconds()
The single LONG_LIVED_TOKEN_TTL_SECONDS constant (Meta 60-day) plus a loose
inline 7200 for X made it unclear which networks each value applied to. Express
the fallback TTL as a per-platform match method instead, matching how the enum
already exposes every other per-network value, so the network->value mapping is
visible in one place: X 2h, Instagram/Threads 60d, everyone else null (they
always return expires_in). Behavior is unchanged.
2026-07-03 13:11:36 -03:00
Paulo Castellano
3d3150bd20 Redact the Threads token-exchange failure logs
The connect flow logged the raw response body of a failed token exchange,
unlike the TokenRedactor discipline used everywhere else. A failure body
carries no token, but redacting keeps it consistent and defensive.
2026-07-03 11:26:48 -03:00
Paulo Castellano
4d5ca6b274 Centralize the Meta long-lived token TTL and clarify the extension helper
The 60-day fallback used when Meta omits expires_in was duplicated as a bare
5184000 across the Instagram/Threads connect and refresh code; it now lives in
one place, Platform::LONG_LIVED_TOKEN_TTL_SECONDS. Also renames
Platform::extensionModelValues() to accessTokenExtendingPlatformValues() so the
name states what it returns without needing the extendsAccessTokenOnRefresh
docblock.
2026-07-03 11:07:54 -03:00
Paulo Castellano
545a779848 Never leave Instagram/Threads with a null token expiry
A null token_expires_at drops an account from every refresh path (the
cron's whereNotNull filter and the is_token_expired / is_token_expiring_soon
checks all treat null as "nothing to do"), so the token silently lapses.
Threads could persist null two ways: the long-lived exchange failing at
connect (kept the ~1h short-lived token) — now fails the connect instead;
and a refresh response omitting expires_in — now defaults to 60 days for
both Instagram and Threads, matching the X refresh convention.
2026-07-03 10:33:26 -03:00
Paulo Castellano
974c076e86 fix(tracking): fire begin_checkout when onboarding checkout starts
trackBeginCheckout was defined but never called, so the begin_checkout
(GTM/dataLayer) and checkout.started (PostHog) events never fired. Wire
it into the onboarding Connect submit handler, before the redirect to
Stripe, and pass the workspace plan from the controller so the event
carries plan name + interval.
2026-07-02 09:21:31 -03:00
Paulo Sérgio Dantas
67fb40d177 feat(ai-create): let users choose brand colors or free AI colors for images
The image pipeline already threads `applyBrandVisuals` through
`TemplateContext` -> `PostImagePipeline` -> `TemplateImageGenerator`, but it was
hardcoded to `true` at the dispatch site, so generated images always used the
workspace brand palette with no way to opt out.

Expose the choice in the create wizard: a "Brand colors" / "Let AI decide"
toggle (shown only when images are generated). The flag flows
front -> `StartPostCreationRequest` (`apply_brand_visuals`) ->
`PostAiCreateController@start` -> `StreamPostCreation` -> `TemplateContext`,
defaulting to `true` so existing behavior is unchanged when the field is absent.
2026-07-01 01:47:57 -03:00
Paulo Castellano
a6bb39cc1a refactor(api): extract inline-media hosting into a HostInlineMedia action
The download+host+422 orchestration was a private controller method doing IO and
throwing — that's an operation, not a controller concern. Move it to
App\Actions\Post\HostInlineMedia::execute() (alongside CreatePost/UpdatePost) so
the controller stays thin and the logic is reusable/testable.
2026-06-28 21:15:01 -03:00
Paulo Castellano
8d7dcdf6eb refactor(social): trim verbose comments + harden X chunked upload from review
Cold-review follow-ups on the PR:
- Trim the oversized docblocks/inline comments added across the API controller,
  MediaAttacher, Post, the publish job, and the X publisher to one line (keeping
  the @param/@return array-shape annotations).
- XPublisher::chunkedUpload now accepts ?string $mediaCategory and only sends
  media_category when present — getMediaCategory() can return null, so the strict
  string param was a latent TypeError (unreachable on X today, removed anyway).
- Fix MediaAttacher docblocks: the file imports Type as MediaType, so the
  @param array<Type> annotations didn't resolve — now array<MediaType>.
- Tests: cover the failed() job hook genericizing a raw error, and X failing
  cleanly (XPublishException) when media can't be downloaded.
2026-06-28 20:37:04 -03:00
Paulo Castellano
81d43c30f4 fix(api): download and host external media URLs on post create/update
The public REST API accepted inline post media as a free-form array and stored
it verbatim, so a client could create/update a post whose media was a bare
external URL we never hosted. Publishing then depended on that third-party URL
staying alive — when it 404'd (e.g. an image proxy), the post failed across
platforms (Facebook 'unsupported media type', X 'HTTP 404', Instagram 'could
not fetch media').

Inline media URLs on create/update now go through the same download + MIME-
validate + host path as the attach-from-url endpoint (MediaAttacher), so the
stored media always points at our own storage. Items already hosted (carrying a
path) pass through untouched. If any URL can't be fetched the request is
rejected with 422 and nothing is persisted, so a post is never created with
broken media. MCP and the web flow were already safe and are unchanged.

- MediaAttacher: extract fetchToWorkspace() + add resolveInlineMedia()
- Post::allowedMediaTypesFor() so the create flow can compute allowed types
  without a persisted post
- API Store/UpdatePostRequest: media.* item rules (mirroring the web; prevents
  validated() from stripping hosted-item keys)
- PostController store()/update(): host external media before persisting
2026-06-28 17:28:05 -03:00
Paulo Castellano
f8f6d263ad refactor(onboarding): send goals to PostHog exactly like persona
Mirror the persona handling: identify the user with a single `goals` person
property instead of an array plus a boolean per goal. Drops the goalProperties
helper.
2026-06-25 21:06:27 -03:00
Paulo Castellano
afc4c7a80b feat(onboarding): add goal step after persona
After picking who they are, users now pick what they want to achieve with
TryPost. A multi-select goal step (12 options + an exclusive "just exploring"
and "something else") sits between the persona step and connect, mirroring the
persona screen's style.

The goals persist to a json column on users and are mirrored to PostHog on
identify (onboarding_goals array plus a boolean per goal), so campaigns can be
cross-tabbed against the intent they actually attracted. connect now requires
both a persona and at least one goal; persona store advances to the goal step.

Options are grounded in TryPost's real capabilities (publishing, AI content,
brand voice, automation via API/MCP, collaboration, analytics) and copy is
localized in en/es/pt-BR.
2026-06-25 20:49:04 -03:00
Paulo Castellano
12a1baa757 refactor(social): address PR review follow-ups
- DeleteWorkspace now reassigns affected users to another workspace they
  belong to (instead of nulling current_workspace_id), so deleting the
  current workspace while owning others no longer bounces to "create".
- Remove the dead LINKEDIN_PAGE_CLIENT_REDIRECT env (the unified flow uses
  a single callback; config no longer reads a page redirect).
- useOAuthPopup uses the Wayfinder connect helpers per platform instead of
  hardcoding /connect/{platform}.
- Unify the Inertia\Response alias in Mastodon/Bluesky controllers to
  InertiaResponse, matching the other connect controllers.
- Add tests: workspace reassignment on delete, and independent refresh of
  a linkedin row vs its linkedin-page sibling.
2026-06-25 15:01:23 -03:00
Paulo Castellano
dba6346226 refactor(auth): split workspace gate into EnsureHasWorkspace middleware
EnsureAccountReady bundled a subscription gate (redirects to onboarding,
SaaS only) with a workspace gate (redirects to workspace creation). The
connect routes can't sit behind it because connecting/disconnecting
happens during onboarding, before a subscription exists.

Split the workspace gate into a standalone EnsureHasWorkspace middleware:

- EnsureAccountReady is now subscription-only.
- EnsureHasWorkspace redirects to workspace creation when there is no
  current workspace, in both SaaS and self-hosted modes.
- The social connect group gains EnsureHasWorkspace; the main app group
  gains it alongside EnsureAccountReady (listed after it, so the
  subscription gate still runs first — no custom middleware priority).
- The repeated `if (! $workspace) redirect()` guard is removed from the
  connect/store/authorize/disconnect/index/toggle handlers, and their
  return types are tightened (no more dangling RedirectResponse).

LinkedIn connect's no-workspace path changes from a popup callback to the
same redirect as the other platforms.
2026-06-25 14:30:15 -03:00
Paulo Castellano
cd2798fd8d refactor(social): use native Inertia for all connect popup flows
Replace the per-platform native form POST + manual CSRF + JSON/Blade
popup callback with a single Inertia mechanism:

- popupCallback() renders the accounts/PopupCallback Inertia page (notifies
  the opener + closes the popup) for both the GET OAuth callbacks and the
  selection submits. Drops the auth.social-callback Blade view, the
  expectsJson JSON branch, and useHttp/useSocialConnect on the frontend.
- Selection/credential pages (LinkedIn, Facebook, Instagram, Bluesky,
  Mastodon) use Inertia useForm: automatic CSRF + native validation errors.
- Unify the three selection screens on one row + View/Choose layout; the
  LinkedIn company tag now uses a building icon.
- Bluesky auth failures throw ValidationException (422 for XHR, redirect
  back with errors otherwise).

Controller tests updated from assertViewIs/assertViewHas to assertInertia.
2026-06-25 13:54:16 -03:00
Paulo Castellano
dec2bc028a fix(linkedin): show connected company page on the accounts and onboarding grids
The unified LinkedIn card grouped connected accounts by looking the platform up in the rendered card list, but linkedin-page is no longer a card — so an org-only connection fell through to its own network key and the card showed 'Connect' for an already-connected page, with no way to disconnect or reconnect. Expose each account's network on SocialAccountResource and group by account.network instead.

Onboarding still built its connect grid from SocialPlatform::enabled() instead of isConnectable(), so it rendered a standalone LinkedIn Page card with a missing logo whose connect button hit the deleted connect/linkedin-page route (404). Filter by isConnectable() to match the accounts page.
2026-06-24 21:23:27 -03:00
Paulo Castellano
d84666360a refactor(linkedin): infer post format from media + unify account connection
Collapse LinkedIn to one content type per account kind (linkedin_post, linkedin_page_post). Publishers infer the publish format from the attached media — text, single image/video, multi-image carousel, or PDF document — matching how facebook_post/x_post already work; PDF is exclusive of any other attachment. Removes the editor variant picker, keeping only the PDF document title field. Includes a data migration collapsing the retired carousel/document content types.

Replace the two LinkedIn account cards with a single Connect LinkedIn button: one unified OAuth grant (linkedin-openid driver, union of scopes) then a post-callback identity picker to post as the personal profile (linkedin) or a company page the member administers (linkedin-page). The chosen organization is validated against the admin-verified list from the OAuth grant. Per-capability gating via LINKEDIN_ENABLED / LINKEDIN_PAGE_ENABLED supports profile-only or org-only self-hosting. Removes LinkedInPageController, LinkedInTokenSynchronizer, the standalone linkedin-page connect routes, and the unused redirect_page config.
2026-06-24 21:05:09 -03:00
Paulo Castellano
dc29d4dd48 fix(permissions): address code-review findings
- store(): members without connected accounts no longer get redirected into
  the now-admin-only /accounts (403); non-managers go to the calendar with
  the same flash, admins still go to /accounts
- cover the SyncPostPlatforms can('update') gate (viewer creates no platform
  rows; member does) and the store redirect split, in WorkspaceRolePermissions
- cover PostPolicy::duplicate viewer-denied
- docs sidebar link uses the canonical https://docs.trypost.it
- drop orphaned sidebar.support.{discord,last_updates} keys in all locales
- remove the explanatory isLocked comment in Edit.vue
2026-06-22 18:00:36 -03:00
Paulo Castellano
1c9ab462d0 feat(permissions): viewers review drafts in a read-only editor; lock /accounts to admins
Viewers are typically the client: they need to open a draft in the editor
to use the comments tab, but must not change anything.

- post editor (edit) now authorizes view, so viewers can open it; the
  composer + schedule tab render read-only and the comments tab stays
  interactive (defaults to the comments tab for viewers)
- all mutations stay member+ (update/delete) — the autosave/save/publish/
  schedule/delete affordances are hidden and the PUT is still 403 for
  viewers; SyncPostPlatforms only runs for users who can update
- drafts route to the editor for everyone again (reverts the read-only
  Show detour); Show stays the published-post view
- /accounts now authorizes manageAccounts (admin+), so viewers and members
  get 403; the Connections sidebar item is admin+ only and the connect/
  disconnect grid is reverted to main (no per-button gating needed)

Tests: draft→editor redirect for every member, viewer can open the editor,
viewer cannot save, and only admins+ can open /accounts.
2026-06-22 17:04:09 -03:00
Paulo Castellano
8b52ea2082 fix(permissions): viewers open posts read-only instead of hitting the editor
A viewer clicking a draft/scheduled post landed on the editor route
(authorizes update) and got a 403. The post list/calendar routed every
editable post to the edit page, and PostController@show redirected
draft/scheduled posts to the editor for everyone.

- show only redirects to the editor when the user can update the post;
  viewers get the read-only Show page
- posts index + calendar route to show (not edit) when the user cannot
  create posts
- cover viewer-sees-show, member-redirected-to-editor, and
  viewer-403-on-direct-edit in WorkspaceRolePermissionsTest
2026-06-22 16:41:27 -03:00
Paulo Castellano
5bb39da598 fix(permissions): enforce workspace roles across backend and UI
Viewers could mutate posts, automations and trigger AI write endpoints,
and every role saw create/manage affordances that 403'd on click.

Backend (security):
- PostPolicy update/delete now require member+ (was tenancy-only), which
  also gates the AI write endpoints that authorize('update')
- AutomationPolicy create/update/delete require member+; activate/pause
  delegate to update
- AutomationController authorizes index/store/show; AnalyticsController
  authorizes view
- Comments stay open to members incl. viewer (by design)

Frontend (UI gating via new useWorkspaceRole composable):
- Sidebar: create post / create workspace / automations / library nav
- Accounts grid: connect / disconnect / reconnect (admin+)
- Members: invite / change role / remove / cancel invite (admin+)
- Account billing tab (owner); posts index + calendar create affordances

Tests: PostPolicyTest, AutomationPolicyTest (all four roles) and an
end-to-end WorkspaceRolePermissionsTest; aligned the automation test
suites' account/workspace setup with role pivots.
2026-06-22 16:31:54 -03:00
Paulo Castellano
f572341e36 fix(members): block self role-change/removal and lock role rules with tests
The remove/role-change guards only protected the account owner, so a non-owner
admin could change their own role or remove themselves via a crafted request
(the UI hides it, but the backend didn't). Add an explicit self-guard to both
updateRole and removeMember.

Lock the whole role system with tests: accept assigns the exact invited role
(viewer/admin/member), invite requires and persists a role, updateRole supports
viewer and blocks self/owner/invalid, removeMember blocks self/owner, and a
viewer is read-only (view yes; create post / manage team / invite no).
2026-06-22 14:41:11 -03:00
Paulo Castellano
6c47bac1b8 fix(members): preserve invited role on accept and surface viewer in role menu
AcceptInviteController attached invited users with a hardcoded member role,
ignoring the invite's role entirely (a viewer invite joined as member). Use the
invite's role on accept, require role on invite creation (no silent default),
and drop the role from the request/CreateInvite defaults. Add the Viewer option
to the member role dropdown (now iterates all roles), hide the dropdown on the
current user's own row, and require the member's email to confirm removal.
Covered by tests asserting the accepted role matches the invited role for
viewer/admin/member, plus invite role validation.
2026-06-22 14:24:19 -03:00
Paulo Castellano
2d07002325 chore: remove vestigial onboarding OAuth flag and legacy plan env vars
The onboarding/connect and accounts screens now share the same NetworkConnectGrid
with the same popup-close-and-reload flow, so the social_connect_onboarding flag
no longer affects anything in the normal path. Remove the frontend plumbing
(useOAuthPopup query param, the grid's onboarding prop) and the dormant backend
scaffolding (the session flag in 6 OAuth controllers, getRedirectRoute, and the
ignored isOnboarding arg); the YouTube no-channels error now redirects to
app.accounts directly. Also drop the unused legacy plan price-id env vars from
.env.example (only the Workspace plan remains).
2026-06-22 13:54:42 -03:00