Commit graph

154 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
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
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
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
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
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
b78d6acf7a chore(settings): drop the language-changed flash banner
Changing the language re-renders the UI in the new locale already, so the
'Language updated' banner was redundant. Remove the flash and its orphaned
i18n key.
2026-06-22 12:32:11 -03:00
Paulo Castellano
bde8d4801e feat(billing): attach persona to conversion tracking and polish annual banner
Send the user's persona on both the server-side subscription.created event and
the client-side checkout.completed/dataLayer purchase event, for ICP analysis.
Hold the processing screen ~5s before redirecting so PostHog and the ad pixels
(Google/Meta via GTM) reliably flush. Sharpen the annual-upgrade banner copy
(lead with '2 months free') and give its check icon a white tile.
2026-06-22 12:32:03 -03:00
Paulo Castellano
09d4e9d6b1 feat(onboarding): require connecting a social network before checkout
Add a connect step between persona selection and Stripe checkout: persona ->
connect >=1 network (server-enforced) -> checkout. Extract the network grid into
a shared NetworkConnectGrid used by both onboarding and the accounts page, which
is redesigned from a table into the same grid (one account per network, with
per-card connect / connected+disconnect / reconnect states). Drop the openDialog
auto-open flow now that networks are shown inline.
2026-06-22 12:31:44 -03:00
Paulo Castellano
aacc4390a2 refactor(billing): replace MonthlyCreditsLimit Pennant feature with BillingCycle
Credit allotment is now derived directly from BillingCycle::for($account)->creditAllotment()
instead of a cached Pennant feature, removing the dynamic cache-invalidation footgun
(forgetPlanFeatureCache) that had to be called from every subscription/workspace mutation.
2026-06-22 09:55:03 -03:00
Paulo Castellano
17a091975d fix: merge-readiness — close two billing/network bugs, harden tests
Bugs (both with regression tests):
- OnboardingController::store now guards already-subscribed accounts (mirrors
  index), preventing a second Stripe Checkout / double subscription if a
  subscribed user re-POSTs /onboarding.
- SocialAccountObserver: drop the `platform_user_id != …` clause from the
  creating-time one-per-network check. On create there is no "self" to exclude,
  so it only weakened the rule — the same account connected via two network
  variants (e.g. Instagram standalone + via Facebook, same id) could slip a
  second account into the network. Now any account in the network blocks.

Robustness:
- CreateWorkspace wraps create + member attach + switchWorkspace in a
  transaction (cache-forget / quantity-sync run after), so a partial failure
  can't leave an orphan workspace that inflates the Stripe seat count — covers
  both the signup and the add-workspace paths.

Test honesty & coverage:
- Scope the ten "connect multiple <platform> accounts" tests to self-hosted
  mode (config + name); they only passed because the test env defaults
  SELF_HOSTED=true, and in cloud the one-per-network rule blocks them.
- network_taken popup now has controller-level tests on all six OAuth
  controllers (added Threads, YouTube, LinkedInPage, and a new
  InstagramFacebook test file; LinkedInPage/InstagramFacebook also exercise
  variant collapse).
- Wiring tests that creating/deleting a workspace actually calls
  syncWorkspaceQuantity (guards per-seat billing against silent breakage).
- Strengthen TrialLengthTest to assert the configured length reaches
  trial_ends_at; add a same-id network-variant block test.
2026-06-22 09:26:31 -03:00
Paulo Castellano
caad3d3e53 refactor: address third-pass review nits
- CreateUser: provision the default workspace OUTSIDE the signup DB transaction
  so CreateWorkspace's cache-forget / Stripe-quantity sync never runs inside a
  transaction (consistent with WorkspaceController::store); user+account stay
  atomic and a failed workspace create degrades to /workspaces/create.
- BillingController::swapToYearly: explicit null guard on the resolved
  subscription before dereferencing stripe_price.
- UsageController: reuse the already-loaded workspaces collection for the count
  instead of issuing a second query.
- Add a GitHub OAuth-callback test covering the default-workspace auto-create
  (mirrors the Google one).
2026-06-22 08:39:25 -03:00
Paulo Castellano
17449bfe2a test: cover network_taken popup on OAuth conflict; use FormRequest accessor
- Add a controller-level test asserting the Facebook OAuth callback returns the
  localized network_taken popup (not a generic error) when the network is
  already connected — covers the bug-A fix across the OAuth callbacks.
- WorkspaceController::autofillBrand: use $request->validated('url') instead of
  data_get on the validated array (idiomatic FormRequest accessor).
2026-06-22 08:01:08 -03:00
Paulo Castellano
0619f2a0f4 refactor: address code review for per-workspace pricing
Bug fix
- Surface the localized "network already connected" message on the
  Facebook/Instagram/InstagramFacebook/LinkedInPage/Threads/YouTube OAuth
  callbacks: catch NetworkAlreadyConnectedException before the generic catch
  so the conflict no longer falls through to a generic error + Log::error.

Scope / dead code
- Remove the orphaned BillingController::checkout() + app.billing.checkout route
  (onboarding starts checkout directly); delete the now-dead DiscordWidget and
  useFeatureAccess composable; prune orphaned i18n keys left by removing the
  plan picker / upgrade dialog / count limits (billing.subscribe.*,
  accounts.limit_reached, workspaces.limit_reached, common.discord.*).
- Drop the unused `plan` prop from the usage page and the unused `label` from
  the onboarding persona payload (labels come from i18n); remove
  Persona::options()/label().

Conventions
- declare(strict_types=1) on the two new migrations.
- Extract autofill validation into AutofillBrandRequest (FormRequest).
- Drop the unused $plan param from AccountPolicy::swapPlan.
- CreateUser: drop the stale config('cashier.trial_days', 7) fallback (now 8).
- Rename LimitEnforcementTest to InvitePermissionTest; use Pest mock() helper in
  WorkspaceQuantitySyncTest; move shared test helpers into Pest.php.
- Memoize BillingCycle window() + subscription lookup.
2026-06-21 21:28:47 -03:00
Paulo Castellano
cbf8fb283c feat: per-workspace pricing, onboarding, and billing overhaul
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
  workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
  on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
  (workspace/social/member) and the legacy plan tiers (single Workspace plan).

Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
  (Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
  (users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
  price. 8-day trial so Stripe displays 7.

Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
  banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.

System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
  (system feature, not the user's usage).

Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
2026-06-21 20:40:03 -03:00
Paulo Castellano
bb55e14423 fix(ai-templates): paragraph spacing bug, registry cache, coverage gaps, empty-account UX 2026-06-17 18:09:02 -03:00
Paulo Castellano
799a151321 feat(ai-templates): template picker in the AI wizard 2026-06-17 17:42:23 -03:00
Paulo Castellano
9dba90129f feat(ai-templates): task 3 - template-driven generator + job
- Add AiTemplateRegistry (resolves, keys, default)
- Add template/templateContext params to PostContentGenerator; schema() delegates to template when both are set; instructions() uses template.promptView()
- Refactor StreamPostCreation.handle() to resolve template via registry, build TemplateContext, delegate assemble() to template; add createPostFromGenerated()
- Remove handleSingle(), handleCarousel(), resolvedContentType() (no other callers)
- PostAiCreateController passes template param from request (default image_card)
- All existing AI tests green; registry test added
2026-06-17 17:23:33 -03:00
Paulo Castellano
c2dd4515b2 feat(channels): add Discord as a social channel
Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.

- Connect: custom Socialite Discord provider (bot scope) maps the authorized
  guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
  channel belongs to the connected guild (anti cross-guild), optimizes media,
  builds allowed_mentions only from explicit mention chips (no accidental pings),
  and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
  embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
  lookup endpoints, i18n (en/es/pt-BR), and tests.

Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
2026-06-16 14:44:00 -03:00
Paulo Castellano
45da024fc6 fix(automations): code-review hardening for feed fetch & URL validation
- inspectFeed fetches via SafeHttpFetcher::get (SSRF guard + timeout + redirect
  cap + UA) instead of a raw, timeout-less Http::get; add a timeout to the node fetch.
- ResolvableUrl now requires an http(s) scheme, so it's no weaker than the plain
  url rule it replaced (rejects file://, javascript://).
- Apply ResolvableUrl to the HTTP Request and Webhook node URLs too, so templated
  {{ }} URLs validate consistently across every URL field.
- Clear a fetch_rss node's discovered_fields when its feed_url changes, so stale
  fields from the previous feed stop showing up as autocomplete suggestions.
2026-06-16 12:09:16 -03:00
Paulo Castellano
246a159f34 feat(automations): multi-format RSS/Atom feeds with dynamic variables
Replace the RSS-2.0-only SimpleXML parser with SimplePie so the Fetch RSS
node reads Atom 1.0 (YouTube, GitHub, The Verge…) and RSS 2.0 + namespace
extensions (dc:, content:, media:, yt:, itunes:). Each item exposes stable
cross-format aliases (title, link, date, content, author, …) plus every
namespaced field flattened for use as {{ fetched.* }}.

Add a feed-inspection endpoint that discovers a feed's real fields and feeds
them into the editor's expression autocomplete. Allow {{ }} expressions in the
feed URL via a ResolvableUrl rule. Parsing moves to a dedicated FeedParser
service; the fetch keeps the SSRF guard and gains XXE-safe parsing.
2026-06-16 10:56:08 -03:00
Paulo Castellano
f223cf14ce Fire checkout.completed reliably for trial-with-card checkouts
A trial-with-card subscription is already subscribed() (status trialing)
by the time the webhook lands, so /billing/processing usually mounts
already-active and the false->true poll transition the event depended on
never happened — only 3 of 66 real subscriptions emitted checkout.completed.

Complete the purchase from whichever path runs first (onMounted when already
active, or the poll transition), de-duplicated per checkout session via a
one-time Cache::add gate on session_id so back-button/refresh can't re-fire.
2026-06-14 16:12:49 -03:00
Paulo Castellano
b9fa8be513 Group Telegram service classes under Services/Social/Telegram 2026-06-14 13:32:03 -03:00
Paulo Castellano
ba246a3ddd Add Telegram analytics: per-post reactions (webhook) + channel subscribers 2026-06-14 11:18:40 -03:00