2026-01-15 01:13:44 +00:00
< laravel-boost-guidelines >
=== foundation rules ===
# Laravel Boost Guidelines
2026-03-29 21:10:10 +00:00
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
2026-01-15 01:13:44 +00:00
## Foundational Context
2026-03-29 21:10:10 +00:00
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 12:54:51 +00:00
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
Before relying on a package's API, confirm its installed version:
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
- JS packages: check `package.json` for the installed versions.
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
## Skills Activation
2026-05-04 17:04:03 +00:00
This project has domain-specific skills available in `**/skills/**` . You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
2026-03-29 21:10:10 +00:00
2026-01-15 01:13:44 +00:00
## Conventions
2026-03-29 21:10:10 +00:00
2026-01-15 01:13:44 +00:00
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts` , not `discount()` .
- Check for existing components to reuse before writing a new one.
## Verification Scripts
2026-03-29 21:10:10 +00:00
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
2026-01-15 01:13:44 +00:00
## Application Structure & Architecture
2026-03-29 21:10:10 +00:00
2026-01-15 01:13:44 +00:00
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
2026-07-23 15:44:14 +00:00
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build` , `npm run dev` , or `composer run dev` . Ask them.
2026-01-15 01:13:44 +00:00
## Documentation Files
2026-03-29 21:10:10 +00:00
- You must only create documentation files if explicitly requested by the user.
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
## Replies
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
=== boost rules ===
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
# Laravel Boost
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
## Tools
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
- Use `database-schema` to inspect table structure before writing migrations or models.
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
## Searching Documentation (IMPORTANT)
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
- Pass a `packages` array to scope results when you know which packages are relevant.
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']` . Expect the most relevant results first.
- Do not add package names to queries because package info is already shared. Use `test resource table` , not `filament 4 test resource table` .
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
### Search Syntax
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
3. Combine words and phrases for mixed queries: `middleware "rate limit"` .
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]` .
2026-01-15 01:13:44 +00:00
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 12:54:51 +00:00
## Project Rules
- This project keeps committed, area-grouped rules in `.ai/rules` (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule.
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**` ), a short `title` , and a few-line `note` . Always use `record-rule` , never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
2026-03-29 21:10:10 +00:00
## Artisan
2026-01-15 01:13:44 +00:00
2026-07-23 15:44:14 +00:00
- Run Artisan commands directly via the command line (e.g., `php artisan route:list` ). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
- Inspect routes with `php artisan route:list` . Filter with: `--method=GET` , `--name=users` , `--path=api` , `--except-vendor` , `--only-vendor` .
- Read configuration values using dot notation: `php artisan config:show app.name` , `php artisan config:show database.default` . Or read config files directly from the `config/` directory.
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
## Tinker
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
2026-07-23 15:44:14 +00:00
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
=== php rules ===
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
# PHP
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
- Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }` . Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
2026-05-04 17:04:03 +00:00
- Use TitleCase for Enum keys: `FavoritePerson` , `BestLake` , `Monthly` .
2026-03-29 21:10:10 +00:00
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks.
2026-01-15 01:13:44 +00:00
2026-05-04 17:04:03 +00:00
=== deployments rules ===
# Deployment
- Laravel can be deployed using [Laravel Cloud ](https://cloud.laravel.com/ ), which is the fastest way to deploy and scale production Laravel applications.
2026-07-23 15:44:14 +00:00
=== herd rules ===
2026-01-15 01:13:44 +00:00
2026-07-23 15:44:14 +00:00
# Laravel Herd
2026-01-15 01:13:44 +00:00
2026-07-23 15:44:14 +00:00
- The application is served by Laravel Herd at `https?://[kebab-case-project-dir].test` . Use the `get-absolute-url` tool to generate valid URLs. Never run commands to serve the site. It is always available.
- Use the `herd` CLI to manage services, PHP versions, and sites (e.g. `herd sites` , `herd services:start <service>` , `herd php:list` ). Run `herd list` to discover all available commands.
2026-01-15 01:13:44 +00:00
=== tests rules ===
2026-03-29 21:10:10 +00:00
# Test Enforcement
2026-01-15 01:13:44 +00:00
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
2026-07-23 15:44:14 +00:00
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
2026-01-15 01:13:44 +00:00
=== inertia-laravel/core rules ===
2026-03-29 21:10:10 +00:00
# Inertia
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns.
- Components live in `resources/js/pages` (unless specified in `vite.config.js` ). Use `Inertia::render()` for server-side routing instead of Blade views.
- ALWAYS use `search-docs` tool for version-specific Inertia documentation and updated code examples.
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
# Inertia v3
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
- Use all Inertia features from v1, v2, and v3. Check the documentation before making changes to ensure the correct approach.
- New v3 features: standalone HTTP requests (`useHttp` hook), optimistic updates with automatic rollback, layout props (`useLayoutProps` hook), instant visits, simplified SSR via `@inertiajs/vite` plugin, custom exception handling for error pages.
- Carried over from v2: deferred props, infinite scroll, merging props, polling, prefetching, once props, flash data.
- When using deferred props, add an empty state with a pulsing or animated skeleton.
- Axios has been removed. Use the built-in XHR client with interceptors, or install Axios separately if needed.
- `Inertia::lazy()` / `LazyProp` has been removed. Use `Inertia::optional()` instead.
- Prop types (`Inertia::optional()`, `Inertia::defer()` , `Inertia::merge()` ) work inside nested arrays with dot-notation paths.
- SSR works automatically in Vite dev mode with `@inertiajs/vite` - no separate Node.js server needed during development.
- Event renames: `invalid` is now `httpException` , `exception` is now `networkError` .
- `router.cancel()` replaced by `router.cancelAll()` .
- The `future` configuration namespace has been removed - all v2 future options are now always enabled.
2026-01-15 01:13:44 +00:00
=== laravel/core rules ===
2026-03-29 21:10:10 +00:00
# Do Things the Laravel Way
2026-01-15 01:13:44 +00:00
2026-07-23 15:44:14 +00:00
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help` .
- If you're creating a generic PHP class, use `php artisan make:class` .
2026-01-15 01:13:44 +00:00
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Model Creation
2026-07-23 15:44:14 +00:00
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
## APIs & Eloquent Resources
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
## URL Generation
2026-01-15 01:13:44 +00:00
- When generating links to other pages, prefer named routes and the `route()` function.
2026-03-29 21:10:10 +00:00
## Testing
2026-01-15 01:13:44 +00:00
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()` . Follow existing conventions whether to use `$this->faker` or `fake()` .
2026-07-23 15:44:14 +00:00
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
## Vite Error
2026-07-23 15:44:14 +00:00
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev` .
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
=== wayfinder/core rules ===
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
# Laravel Wayfinder
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
Use Wayfinder to generate TypeScript functions for Laravel routes. Import from `@/actions/` (controllers) or `@/routes/` (named routes).
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
=== pint/core rules ===
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
# Laravel Pint Code Formatter
2026-01-15 01:13:44 +00:00
2026-07-23 15:44:14 +00:00
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent` , simply run `vendor/bin/pint --format agent` to fix any formatting issues.
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
=== pest/core rules ===
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
## Pest
2026-01-15 01:13:44 +00:00
2026-07-23 15:44:14 +00:00
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}` .
- The `{name}` argument should not include the test suite directory. Use `php artisan make:test --pest SomeFeatureTest` instead of `php artisan make:test --pest Feature/SomeFeatureTest` .
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName` .
2026-03-29 21:10:10 +00:00
- Do NOT delete tests without approval.
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
=== inertia-vue/core rules ===
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
# Inertia + Vue
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
Vue components must have a single root element.
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
< / laravel-boost-guidelines >
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
# Project-Specific Rules
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
## Frontend (Vue/TypeScript)
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
- Always use arrow functions in Vue components and TypeScript files. Never use `function` declarations.
2026-01-15 01:13:44 +00:00
Connect the Pages a login only reaches through a Business Portfolio (#301)
* fix: Facebook Page fetch missing New Pages Experience pages
/me/accounts silently omits Pages that live under Meta's newer
"New Pages Experience" / Business Portfolio model, even when the
token's granular scopes show the Page was explicitly granted -
confirmed via Meta's own Access Token Debugger against a live
account whose Page returned zero results from /me/accounts but
resolved fine when queried directly by ID.
Falls back through Business Manager's owned_pages/client_pages
(via the existing business_management scope) when /me/accounts
comes back empty, so Pages under that model are still found.
* fix: Instagram-via-Facebook has the same New Pages Experience gap
Same root cause and fix as the FacebookController fetchPages()
fallback - the Page/Instagram-linked-Page lookup goes through the
same /me/accounts call and is subject to the same Meta-side gap.
* refactor: one place finds every Page a Meta login can publish to
Both controllers walked /me/accounts and, when it came back empty, the
Business Portfolio edges behind it. ManagedPages now owns that walk for
the Facebook and Instagram-via-Facebook flows alike.
Three behaviour changes come with it:
The portfolio edges are read on every connect, not only when /me/accounts
is empty, and merged by Page id. Someone holding one Page by a classic
role and the rest through a portfolio was auto-connected to that single
Page and never offered the others.
/me/businesses is read only once /me/permissions confirms the login
granted business_management, and a failure anywhere along the portfolio
walk leaves the /me/accounts list standing. It used to escape into the
callback's catch, turning "no pages" into "could not connect" for every
login without the scope.
Pages the login cannot get an access_token for are dropped. Connecting
one produces an account that cannot publish.
* test: pin the Page a login only reaches through a portfolio
Covers the merge with /me/accounts, the access_token filter, the
business_management gate, and a failing portfolio edge leaving the
/me/accounts list intact — plus the connect flow end to end on both
Meta platforms.
The Instagram-via-Facebook request count moves from five to six for
the /me/permissions check.
* refactor: read the portfolio edges without asking permission first
The /me/permissions check saved a rejected /me/businesses call on logins
without business_management, at the cost of running a path nobody had
verified against a live account. The edges already fail soft, so the
check bought log tidiness and nothing else.
* test: cover the portfolio walk's remaining shapes
The multi-Page selection flow behind a portfolio — the case #292 asked a
maintainer to check — plus pages spread across two portfolios, a
paginated edge, a portfolio entry with no id, and a portfolio page
merging with one /me/accounts already returned.
* test: stop the Meta connect tests from calling Graph for real
Http::fake only stubs the URLs it is given; anything else goes out over
the network. These files stubbed /me/accounts and left /me — and now
/me/businesses — unstubbed, so the suite was issuing live requests to
graph.facebook.com on every run. They came back 400 and the code under
test swallowed them, so nothing ever went red while the assertions were
measuring Meta's answer instead of the fixture's.
Every Graph call the connect flow makes is stubbed now, and the files
prevent stray requests so a missing one fails loudly. Inertia's SSR
endpoint is allowed through; it is not what these tests are about.
* fix: tell a denied portfolio edge apart from a throttled one
GraphPaginator throws so no caller reads a failed fetch as an empty list
and auto-connects whatever arrived first. Swallowing that exception on
the portfolio edges gave the invariant away: a 429 on owned_pages left
the merged list holding only the /me/accounts page, and the callback
connected it with no picker.
The exception now carries whether the failure was transient, classified
by GraphError, which already owns Meta's rate-limit and transient code
table. A denied permission reads as "this login reaches no portfolio
pages"; a throttle, a 5xx or a truncated walk is raised.
The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each
portfolio costs two more paginated edges inside a synchronous OAuth
callback, and nothing bounded that loop.
* fix: three ways the portfolio walk misread what Meta returned
Concurrent Instagram lookups. The Instagram description ran one request
per Page, in sequence, at a 15s timeout each. That list used to be the
Pages someone holds a role on — a handful. It is now the union with
every portfolio's owned_pages and client_pages, so a portfolio holding
hundreds of Pages serialised the OAuth callback past any gateway
timeout, for exactly the accounts the portfolio walk exists to reach.
Meta's ids= batching is no help: each Page carries its own access token
and one call takes one token. The lookups run in concurrent rounds.
A Page without a token is not a Page you don't have. Meta lets someone
decline pages_read_engagement on its per-permission toggles and still
lists the Page, without an access_token. Dropping it inside the walk
left the caller saying "no Pages found, you need to be an admin of at
least one" to an admin. ManagedPages returns everything Meta listed and
publishable() separates what can be posted to, so the callbacks can
tell the two apart and say which one happened.
Stored scopes are what Meta granted. The scope list was written to the
account's scopes column straight from the request, claiming access the
login may have refused — business_management above all, which needs
Advanced Access and is declined by default without it. It now comes
from /me/permissions, falling back to the request when Meta cannot be
asked.
* fix: only drop a scope Meta says was refused
PublishToSocialPlatform::failForMissingScopes() blocks a post when a
platform's required publish scope is absent from the account's scopes
column, so writing that column from /me/permissions can dead-end an
account. Meta does not document that the endpoint echoes scope strings
verbatim, and the edge is paginated, so a scope it never mentions is
unknown rather than refused and stays. Only declined and expired drop.
* fix: keep the portfolio walk honest and cheap
Raise instead of truncating. The ceiling logged a warning and returned
whatever fit, which is the one thing this module refuses to do
everywhere else: if the walk cannot finish, the real list is unknown,
and a truncated list holding exactly one Page would have been
auto-connected without ever showing the picker. It now raises, and the
ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no
longer pays for it serially.
Read the edges concurrently. Up to two paginated edges per portfolio ran
back to back inside the OAuth callback. They run in rounds now; a URL
that does not come back cleanly still goes through GraphPaginator, which
owns the single place that logs a Graph failure and decides whether it
is a rejection or an unknown.
Prefer the record that carries a token. Merging kept whichever copy of a
Page id arrived first, and /me/accounts always arrives first — so a Page
listed there without a token buried the portfolio copy that had one, and
the login was told its permission was missing for a Page it could reach.
Describe only the Instagram accounts that survive. The per-Page lookup
ran before filterConnectableIdentities discarded them, spending a
BUC-rate-limited call on every Page only to throw the answer away. The
filter reads instagram_business_account.id straight off the raw Page, so
it needs no lookup to run first.
Two Instagram tests mocked Socialite without usingGraphVersion, so the
callback threw, the generic catch answered, and asserting only
success=false passed on the error path instead of the one under test.
* test: pin that pages survive past the first pooled round
The concurrency test drove 30 portfolios with every edge empty, so the
merge across rounds was never exercised with data in it.
* fix: keep the paging-host guard on the pooled edge walk
GraphPaginator refuses to follow a paging.next that points off the host
the walk started from, so a tampered response cannot carry the access
token somewhere else. Reading the first page of each edge through the
pool and handing its paging.next straight back to GraphPaginator made
that URL the *start* of a new walk, which is the one URL the guard
trusts implicitly — so the first hop went unchecked.
The host is compared before the hand-off now, and a mismatch re-walks
the edge from the beginning so GraphPaginator's own guard is what
refuses it, with its logging.
* fix: stop a cut-short walk from passing for a complete one
optional() was written for "this edge may be forbidden" and answers a
rejection with an empty list. Following paging.next through it gave a
rejected cursor the same answer: page one of a 250-Page portfolio came
back and the rest was dropped, and a single connectable Page in that
fragment would have been auto-connected with no picker. The same hole
sat on /me/businesses, where GraphPaginator is all-or-nothing — a
failure on page two threw away the portfolios page one had already
listed, degrading the connect back to /me/accounts alone in silence.
The exception now carries how many pages arrived. Only a rejection on
the very first request reads as "this edge is not readable"; anything
after that is a fragment and raises. Cursors skip optional() entirely.
A login Meta reports as refusing business_management also stops walking
the edges at all. The controllers already read /me/permissions for the
scopes column, so the answer costs nothing, and the walk was otherwise
spending a request on a certain 403 — and logging it at error level —
on every successful connect by such a login.
An Instagram account with an empty Name connected as display_name null:
data_get's default only fires on an absent key, and describeRound always
writes the key.
* test: pin reconnecting a card only the portfolio still reaches
A card whose Page moved behind a portfolio is the reconnect shape of the
bug this branch fixes, and nothing covered it: the walk has to find the
Page, and filterConnectableIdentities has to keep the original card
rather than offering the portfolio's other Pages.
* fix: an unreadable portfolio must not deny the pages that were readable
The portfolio edges are additive, but every failure in them was raised
and the callback's generic catch turned it into "error connecting" — so
one throttled edge among sixty denied a login the Pages /me/accounts had
already returned, and each retry burned more of the quota that caused it.
Only /me/accounts failing is fatal now; everything else marks the walk
incomplete and keeps what arrived.
What the raise was protecting is kept where it belongs: a lone Page is
only taken without asking when the walk saw everything, or when a
reconnect has already pinned which Page is wanted. Otherwise the picker
opens, and the login can see for itself that its Page is not there.
The ceiling stops pretending. It compared the count after walking every
page of /me/businesses — up to ten thousand ids — so the runaway it
existed to bound had already happened. One request, one page, and more
portfolios than that is an incomplete walk rather than a failed one.
A pooled edge that fails is classified where it lands instead of being
re-fetched, halving the cost of the common client_pages rejection, and
GraphPaginator logs a confirmed rejection at warning: it is Meta
answering the question, not something going wrong.
A login that declined the permission its platform needs to publish is
refused at connect. Meta issues a Page token off pages_show_list, so
declining pages_manage_posts still produced a green account whose every
scheduled post was then hard-failed by failForMissingScopes.
Test fakes address Graph through the config rather than a literal host,
which is what CLAUDE.md asks for and what the newer tests already did.
* fix: an incomplete walk must not answer as if it were sure
Marking the walk incomplete stopped the auto-connect, but every dead end
after it still gave a definitive answer. A login whose only Pages sit
behind a throttled portfolio was told "no Facebook Pages found, you need
to be an admin of at least one" — the exact sentence this branch exists
to stop showing to admins, now arriving for a different reason. The
already-connected and missing-permission answers were equally sure of
themselves.
When the walk could not see everything and there is nothing to offer,
it says so and asks for a retry.
* fix: stop every Inertia test from calling an SSR server
inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned
it off, so every test rendering an Inertia page issued a real request to
the SSR endpoint. The project does not use SSR, so those calls only ever
failed and fell back to client rendering — quietly, on every run.
Defaulting it off is what the project already assumed, and it retires
the allowStrayRequests hole the Meta connect tests were carrying to work
around it. INERTIA_SSR_ENABLED still turns it back on.
* fix: a taken slot is a fact, not a guess about the listing
Routing every short listing to "try again in a moment" swallowed
network_taken: a workspace that already holds its one Facebook account
was told to retry, forever, whenever a portfolio edge was throttled. That
answer comes from our own rows and does not depend on how far the walk
got. all_connected and page_not_found do, and still yield.
Also: an off-host cursor now stops the edge instead of re-reading page
one, which cost a request and could follow an on-host cursor on the
retry, quietly undoing the guard. Cursor follow-ups are budgeted, since
they cannot be pooled and were the one unbounded serial path left. The
exception's fetched count lost its last reader two commits ago and is
gone. Comments trimmed throughout.
* fix: bound the cursor walk by requests, and keep what it read
MAX_CONTINUATIONS counted edges, not requests: each one then handed off
to GraphPaginator, which follows up to a hundred more pages by itself.
The budget the docblock promised was fifty times larger than it claimed.
Cursors are now followed one budgeted request at a time, so the count
means what it says, and pages already read survive a cut-off instead of
being thrown away with the exception.
A refused /me/businesses is no longer read as "this login has no
portfolios". For a single edge a rejection answers the question; for the
index of edges it means we could not look — and answering complete there
auto-connected the one /me/accounts page while hiding every portfolio
Page, which is this branch's own bug wearing a different hat.
SSR goes back to its shipped default. Turning it off in config to quiet
the test suite would have disabled it wherever it is actually started —
docker/Dockerfile builds the bundle. phpunit.xml carries the switch now,
next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why.
* refactor: one Meta connect flow instead of two kept in step by hand
The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five
lines: the profile touch Meta's review wants, the granted-scope read and
the publish-scope refusal, the page walk, and the answer for a walk with
nothing to offer. They only matched because both were edited side by side,
every round, which is a guarantee nobody should be making by hand.
graphApi() moves to SocialController and reads the host by platform value,
so it serves every network rather than the two that had copied it, and
graphVersion() derives from it instead of reading config a second time.
select() stays as it is. The two differ in the middle — different identity
keys, different connect shapes — and folding them would be abstraction for
its own sake.
* fix: default Inertia SSR off, where this project already stands
Nothing in the repo starts an SSR process, so the shipped default was
describing a setup that does not exist. With it off the test env needs no
override of its own, and CLAUDE.md records that turning it on means
starting the process, not just flipping the env.
* fix: one rule for a refused portfolio, and a clock on the walk
Last round I made a refused /me/businesses mark the walk incomplete, on
the argument that refusing the index means "we could not look". That was
wrong in the case that matters most: an app without Advanced Access for
business_management gets that refusal on every single connect, so every
login on such an install lost auto-connect and every login without Pages
was told to retry forever. Self-hosted in Live mode is exactly that.
The rule that holds everywhere: a Page this login cannot enumerate is a
Page it cannot get a token for, so it was never connectable, and the list
of connectable Pages is complete. Only an unknown — a throttle, a hiccup,
a budget or a ceiling — leaves the walk unable to vouch for itself. Index
and edge now answer the same way, which is also what makes the two
readable together.
The per-request budgets were each bounded while their sum was not: ten
pooled rounds plus twenty-five cursor requests can outlive nginx's
fastcgi_read_timeout of 120s. The walk now carries a deadline and returns
what it has.
touchProfile exists only because Meta's review wants the call. It had no
timeout and no guard, so a hung /me could stall the callback to the
gateway timeout or fail a connect outright, over a response nobody reads.
* docs: the walk's contract changed under its own docblock
It still said any failure marks the walk incomplete, which stopped being
true when a refusal became an answer. A docblock describing an invariant
the code no longer holds is how this branch got two of its bugs.
* fix: put the whole callback inside the budget it advertises
meta_page_walk_seconds bounded the portfolio half of the walk and nothing
else. /me/accounts could paginate a hundred pages at fifteen seconds
each, and the Instagram lookups pooled in rounds that were themselves
serial — a portfolio with three hundred linked Pages is fifteen rounds,
after the walk had already spent its own budget. Both honour the deadline
now. The lookups skip rather than drop: the Page still connects, only its
handle and avatar arrive empty. The first request is always made; the
budget bounds what comes after it.
A Graph body that is valid JSON but not an object — a proxy answering
"throttled" — reached GraphError::isTransient, whose parameter is ?array,
and under strict_types raised a TypeError. That is an Error, so it walked
past both callbacks' catch(\Exception) and 500'd the popup instead of
showing a message.
Refusing a login before any listing has happened no longer borrows the
wording for "we found your Pages but not the permission to post to them".
composer run dev no longer starts an SSR process for SSR that is off, and
CLAUDE.md no longer claims nothing in the repo starts one, which
composer.json contradicted.
* fix: say what actually gets a Page token, per Meta's own reference
The Page node reference is explicit: access_token is "only returned if
the User making the request has a role (other than Live Contributor) on
the Page". Being an admin of the portfolio that owns a Page lists it but
does not grant that role, so the walk can surface Pages this login will
never get a token for.
The popup told those users to reconnect and accept every permission,
which cannot produce a Page role and so could never work. It now names
the role as well.
I rejected this in review on the grounds that a portfolio Page had been
published to successfully in the wild. That proved a token comes back
when the login holds a role, not that one always does.
* fix: one budget for the callback, not one per phase of it
The walk and the Instagram lookups each opened a full
meta_page_walk_seconds, on top of the profile touch and the permission
read, so the callback's worst case was several times the single bounded
budget config/trypost.php advertises. They share one deadline now, taken
once and passed down. META_PAGE_WALK_SECONDS joins .env.example.
An Instagram account described past that deadline arrived with no handle
and no name, and a lone one was then persisted with display_name null —
a blank, unidentifiable card. It falls back to the Page's own name.
Two docblocks were describing behaviour the code does not have.
/me/accounts is the base every other Page is added to, so running out of
budget there aborts rather than degrades, and the class now says so
instead of promising a partial list. GrantedPermissions justified
treating an absent permission as unknown but said nothing about a failed
request, which lands in the same place for a different reason.
* fix: a Pages throttle on a user token was reading as a refusal
Meta's BUC rate-limit table lists code 32 for the Pages API when called
with a User token. GraphError did not carry it, because until this branch
nothing in the app called a Pages surface that way — the publishers use
Page tokens, where the same throttle arrives as 80001.
The portfolio walk does: /me/accounts, /me/businesses and both edges are
read with the user token straight out of OAuth. So an ordinary throttle
came back as code 32, was classified as a confirmed rejection, and the
walk concluded this login simply reaches no portfolio Pages — vouching
for a list missing all of them and auto-connecting whatever /me/accounts
happened to hold. A rate limit was producing the exact silence the
complete flag exists to prevent.
* fix: a reconnect no longer loses its handle to a slow Graph
persistIdentity updates a reconnected card with whatever it is handed,
so a described-with-nulls card overwrote a working account's username
and avatar. Skipping the Instagram lookup — which the shared budget now
does whenever the walk spent it — produced exactly that card. A lookup
that never ran says nothing about a handle the account already has, so
those two keys are left out when it did not.
Refusing the portfolio index goes back to marking the walk incomplete. I
had it that way, reversed it, and this settles it: Meta's Page reference
returns access_token for a Page the login holds a role on, and a Page can
carry that token on a portfolio edge while /me/accounts omits it — which
is this branch's entire premise. So refusing one edge does say those
Pages are unreachable, but refusing the index says no edge was read at
all, and the Pages behind it may well have been connectable. Silently
vouching for a list without them is the original bug.
The budget also starts before the walk and now shapes each request's own
timeout, so no single call can outlive it by fifteen seconds.
composer dev:ssr was a slower alias of composer dev once the SSR process
came out of it.
---------
Co-authored-by: StoriaJames <james@storia.tech>
2026-08-26 13:42:59 +00:00
## Inertia SSR
- This project does **not** run Inertia SSR. `config/inertia.php` defaults `ssr.enabled` to `false` and nothing in the repo sets `INERTIA_SSR_ENABLED` .
- Keep it off. With it on, every test rendering an Inertia page issues a real HTTP request to the SSR endpoint, which fails silently and falls back to client rendering — slow, and it hides missing `Http::fake()` stubs.
- The build wiring is still shipped (`resources/js/ssr.ts`, `vite.config.ts` , `npm run build:ssr` in `docker/Dockerfile` ). Turning SSR on means building that bundle and running `inertia:start-ssr` alongside the app, not just flipping the env.
2026-05-21 19:27:55 +00:00
## Dialogs
2026-07-18 13:58:07 +00:00
- In `<DialogFooter>` , put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col` on mobile (primary on top, cancel at the bottom) and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
2026-05-21 19:27:55 +00:00
- Match sibling dialogs in the same feature area before inventing a new footer layout.
2026-05-21 21:53:37 +00:00
## AI agents (`app/Ai/Agents`)
- **Never** embed prompts in PHP (`<< < PROMPT `, heredocs , or long string literals in `instructions()` ).
- Put system/instruction text in Blade under `resources/views/prompts/` (e.g. `prompts.post_content.generator` , `prompts.post_image.regenerator` ).
- In `instructions()` , return `view('prompts....', [...])->render()` and pass only the variables the Blade file needs — same pattern as `PostContentStreamer` , `PostContentReviewer` , and `BrandAnalyzer` .
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 23:40:03 +00:00
## System AI (always allowed, never metered)
- The brand analyzer / workspace autofill (`App\Services\Brand\BrandAnalyzerRunner`, `App\Actions\Ai\AutofillBrand` , `WorkspaceController::autofillBrand` ) is a **system** feature, not the user's AI usage. It runs during workspace creation, before the user has AI access.
- It MUST always be allowed: NEVER gate it behind the `useAi` policy, an active subscription, or a credit check.
- It MUST NOT deduct anything: NEVER call `RecordAiUsage` (or otherwise consume the account's credits) for brand analysis. Cost is the platform's, not the user's.
- Any future "system" AI helper (runs as part of the platform, not on behalf of a workspace's metered quota) follows the same rule: ungated and unmetered.
2026-08-08 00:25:32 +00:00
## Stripe Checkout (env knobs)
Checkout options are configured only via env — do not hardcode trial/coupon/promo behavior in controllers. All of it goes through `App\Support\Billing\ConfigureSubscriptionCheckout` (called from `StartSubscriptionCheckout` ).
| Env | Config | Default | Effect |
| --- | --- | --- | --- |
| `REQUIRE_CARD_FOR_TRIAL` | `trypost.billing.require_card_for_trial` | `true` | `true` : app access only after Stripe Checkout (no generic signup trial). `false` : generic `accounts.trial_ends_at` trial without a card |
| `CASHIER_TRIAL_DAYS` | `cashier.trial_days` | `8` | Card-required Checkout: `trialDays(N)` for **first-time** subscribers when no first-month coupon is applied (`0` = off). Re-subscribers skip trial. No-card mode: length of the generic signup trial |
| `STRIPE_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_id` | empty | Optional. When set for a qualifying first-time single-workspace checkout, applies `withCoupon` and **skips** trial. Empty = trial mode |
| `CASHIER_ALLOW_PROMOTION_CODES` | `cashier.allow_promotion_codes` | `false` | When `true` and no coupon is applied, show the Checkout promo-code field |
Standing constraints:
- Stripe rejects `discounts` (coupon) and `allow_promotion_codes` on the same session — if both would apply, `ConfigureSubscriptionCheckout` must throw (fail loud). Never “prefer one silently.” Envs may both be set when the account does **not** qualify for the coupon (no throw).
- A set first-month coupon wins over trial (`trialDays` is skipped for that checkout).
- Empty coupon + card required + first-time must use `trialDays` — do **not** reintroduce a required-coupon throw.
- Coupon qualification stays: card required, exactly one workspace, no prior real subscription (`incomplete` / `incomplete_expired` still qualify).
- Prefer documenting durable billing decisions here (and in `AGENTS.md` ) — do **not** create a `.ai/` rules folder for this project.
Allow multiple social accounts per network via env (#286)
* feat: expose self-hosted mode to the accounts UI
SocialAccountObserver already bypasses the one-account-per-network
guard when trypost.self_hosted is true, but the frontend had no way
to know that and always collapsed a network to a single card once
any account existed - so self-hosted deployments could not surface
a second LinkedIn (or Instagram) connection even though the backend
would allow creating it.
* feat: allow connecting multiple accounts per network when self-hosted
NetworkConnectGrid always collapsed a network (LinkedIn profile/page,
Instagram standalone/Facebook) to a single card once any account
existed, with no way to trigger another OAuth flow - even though
SocialAccountObserver already allows unlimited accounts per network
in self-hosted mode. A self-hoster connecting their personal LinkedIn
profile had no path back to the connect flow to also add a company
page (or a second company page/showcase page).
Render one card per connected account instead of collapsing to the
first, and keep a standing "Connect another" card available for a
network's existing connections when self-hosted. Hosted mode is
unchanged: still one card per network, matching the backend's
still-enforced one-account-per-network limit there.
* test: cover the selfHosted prop on accounts and onboarding pages
Backend behavior for connecting a second identity per network in
self-hosted mode was already covered (LinkedInControllerTest,
NetworkUniquenessTest) - these just confirm the new prop the frontend
now depends on is actually present and reflects config correctly.
* style: apply prettier formatting
Pre-existing drift in this file unrelated to the selfHosted change.
* refactor: read selfHosted from shared Inertia props
The flag is already shared by HandleInertiaRequests, so the accounts
and onboarding controllers do not need to pass it again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: gate multiple social accounts with a dedicated env
Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: tighten multiple-account gates after review
Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: bind reconnect to the card and unique social identity
Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: build the OAuth URL before opening the popup
Keep the popup opener URL-only so reconnect query params are assembled at the call site.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: drop dead social-account guards and slim the connect grid
Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: scope social reconnect to the current network
Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: slim connectable-identity filtering
Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: slim social identity persist helpers
Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: keep reconnect updates on the original social card
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: run the suite with multiple social accounts enabled
phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the
true value in .env.ci. That broke eight tests across Automation, MCP,
PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only
needed two accounts of one network as a fixture, not as a rule under test.
Match .env.ci instead. Every test that exercises the one-per-network rule
already sets the config itself; the accounts index test was the only one
leaning on the implicit default, so it now pins it.
* fix: align the multi-account fallback with the self-hosted default
allow_multiple_social_accounts fell back to env('SELF_HOSTED', false)
while self_hosted itself defaults to env('SELF_HOSTED', true). A
self-hosted install that never wrote SELF_HOSTED to its .env resolved to
false and silently lost multiple accounts per network on upgrade, which
is the opposite of what the documented fallback promises.
* fix: collapse duplicate identities before adding the unique index
Installs predating the index can hold the same identity twice: the
network guard was bypassed for multi-account installs and Pinterest
always created a fresh row. Creating the index on that data aborts
migrate mid-deploy.
Keep the newest row per identity and move its post_platforms over before
dropping the duplicates - the FK is nullOnDelete, so deleting outright
would orphan drafts and scheduled posts.
* fix: refuse a reconnect that authorized a different identity
connectIdentity overwrote platform_user_id with whatever the provider
returned, so reconnecting a card while signed into another account
repointed the row - and every draft and scheduled post bound to it - at
a stranger. LinkedIn guarded this at the controller and Facebook via its
filtered page list; nothing covered X, TikTok, Threads, Discord,
Bluesky, Mastodon, Pinterest, Instagram or Telegram.
Enforce the identity match at the single choke point every connect flow
goes through. Every call site already maps NetworkAlreadyConnectedException
to network_taken, so the refusal surfaces without new plumbing.
Also restore the null-platform guard in the observer: occupiesNetwork
type-hints a non-nullable Platform, so a row without one died with a
TypeError instead of the database's NOT NULL error.
* fix: filter connectable identities on every picker step
YouTubeController::select re-fetched the channels and matched the posted
id straight off the raw list, unlike callback and selectChannel. With a
live youtube_oauth session it let a POST name any channel the Google
account owns and bind it to the reconnect target. It also read the
reconnect from the session while the connect below it read
youtube_oauth.reconnect_id, so the two could disagree - pass the
resolved account through instead.
filterConnectableIdentities also short-circuited in multi-account mode,
and the unique index is scoped to platform rather than network. That let
one Instagram account connect twice, once directly and once via
Facebook, publishing every Instagram post to it twice. The existing
except() already spans networkPlatformValues(), so dropping the
short-circuit closes it.
* refactor: type the connect cards and drop the dead accounts grid
The cards computed inferred account as a required ConnectedAccount and
then pushed undefined onto it (TS2345). CI only runs eslint so it stayed
green, but vue-tsc and editors flag it.
SocialAccountsGrid is referenced nowhere; its reconnect button was
updated in this branch without passing the card id, which would have
been a bug had anything rendered it.
* test: keep the suite on the cloud one-account-per-network default
CI runs the Cloud build, so the suite baseline should be the Cloud
default rather than the self-hosted one. Put phpunit.xml and .env.ci
back to false and make the eight tests that merely need two accounts of
one network as a fixture opt in for themselves.
This also un-deads the config()->set(true) calls the branch had already
added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and
SocialAccountObserverTest, which the forced true had turned into no-ops.
* fix: connect standalone instagram instead of reopening the picker
The picker emits an already-resolved connect method, but this branch
rewired @select from openOAuthPopup to startConnect. startConnect sends
a bare 'instagram' straight back into its own picker branch, so choosing
"Instagram" closed the dialog and immediately reopened it - the OAuth
window never opened and the standalone flow was unreachable. Only the
via-Facebook button still worked.
Split the URL-opening tail out of startConnect and let the dialog call
that directly.
* fix: reject a telegram reconnect before burning the connect code
The nonce was consumed before connectIdentity ran, so posting /connect in
the wrong chat spent the one-off code and forced the user to generate a
new one. Check the identity first and report wrong_chat instead of
network_taken, which told them to disconnect an account when the real fix
was posting in the channel they were reconnecting.
* fix: leave one target per post when merging duplicate accounts
post_platforms has no unique on (post_id, social_account_id), so a post
holding a row per duplicate account ended up with two enabled rows aimed
at the surviving account and would publish to it twice. Keep one row per
post, preferring a published one so history survives.
* refactor: collapse the repeated connect-flow boilerplate
Four shapes were copy-pasted across the connect controllers:
- the session + permission guard opening 16 actions, now connectWorkspace()
throwing a ConnectPopupException that renders the popup itself
- the reconnected/connected ternary in 13 places, now connectedCallback()
- the "nothing left to connect" branch in 4 places, now
noConnectableIdentities()
- validatedReconnectId() re-querying what reconnectAccount() already does
Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect
account three or four times per callback; it is resolved once and passed
down. The three GET pickers skipped the manageAccounts check their POST
siblings had, and pick it up from the shared guard.
Drops the color key from connectableOptions and the matching frontend
field - nothing read it. Platform::color() stays; the disconnection
emails use it.
* fix: keep an expired connect popup out of the error log
ConnectPopupException escapes to the framework handler so it can render
itself, which also meant report() ran first: every session_expired and
workspace_not_found popup filed an ERROR and a Nightwatch issue for what
used to be a silent return. A stale popup is a normal outcome, so it now
implements ShouldntReport.
The Mastodon and Threads guards also cleared their provider session
after connectWorkspace(), so a workspace that vanished mid-flow left the
client secret and the OAuth state behind. Clear first, then resolve.
clearMastodonSession() no longer touches social_connect_workspace -
whatever closes the popup already does.
* fix: stop telling users to disconnect an account that is not the problem
Two flows reused popup_callback.network_taken - "This workspace already
has an account for this network. Disconnect it first." - for situations
where that is neither true nor actionable:
- reconnecting a card while signed into a different account on the
provider, now wrong_account
- an empty picker in multi-account mode, where every page or channel on
that login is simply already connected, now all_connected
NetworkAlreadyConnectedException carries the message key so the catch
sites stay one line. handleCallback() also drops its $platform argument;
it read $this->platform for the reconnect lookup and the identity filter
either way, so a caller passing a different platform would have scoped
the lookup to the wrong network.
* refactor: filter linkedin identities with the shared helper
The picker hand-rolled its own reconnect narrowing because the profile
and the pages arrive in two different shapes. Flatten them into one pool
of LinkedIn identities, run the shared filter, and split them again for
the view - the same path Facebook, YouTube and Instagram already take.
Side effect worth having: the picker previously only narrowed on a
reconnect, so it would offer an identity that is already connected and
only fail once the user picked it. It now hides taken identities up
front and says so when nothing is left.
* fix: keep the linkedin picker's own empty state
Routing the picker through the shared filter made every empty pool look
like "nothing left to take", including the pool LinkedIn never filled.
A self-hoster running pages-only who administers no page was told the
network was already connected, or that every account on the login was
taken - both false - and the picker's own "you are not an admin of any
LinkedIn page" state became unreachable.
Only treat it as taken when filtering is what emptied it. Splitting the
pool back also compared the person id loosely on one side and strictly
on the other; one predicate now drives both.
Threads had two forget() calls for a key the top of the action already
clears, and YouTube's picker resolved the reconnect account twice on the
failure path.
* fix: keep the enabled row when collapsing duplicate post targets
SyncPostPlatforms seeds a disabled post_platforms row for every account
in the workspace, so the usual duplicate is one row the user actually
checked next to one they never saw - both pending, both created in the
same second. Ordering only by published-then-newest made that a coin
flip, and PublishPost iterates enabled() only, so half the time a
scheduled post would silently stop reaching that account and take its
caption and per-platform meta with it. This runs once against production
data and the dropped row is gone, so enabled now beats disabled.
Also: the empty-pool exit from the LinkedIn picker was the only one
leaving linkedin_pending - and its tokens - in the session. The
rationale comments move to the docblocks they belong in, and usePage()
comes out of the cards computed.
* fix: stop the migration destroying publish history and automations
Two ways the one-shot merge lost data that cannot be rebuilt:
Surplus published post_platforms rows were deleted. Two duplicate
accounts really could each have published, and each row carries the
platform_post_id for a live post on the network - dropping one leaves
that post unmanageable and invisible to metrics. The docblock claimed
published beat everything; now the code does, and only unpublished
repeats collapse.
Automation nodes persist social_account_id inside a JSON column with no
foreign key, so deleting the loser left RunGenerateNode skipping that
target, or generating nothing at all when it was the node's only
account. The ids are rewritten - current and legacy shapes both - and
entries the merge just turned into duplicates are collapsed.
Ordering is now total (null created_at sorts oldest on every engine,
then id) so a rehearsal on a replica keeps the same rows as the real
run. The LinkedIn picker also passes onboardingProgress inline: it
clears linkedin_pending on the empty path, and a deferred reload would
re-GET the route and swap the empty state for a session-expired popup.
* fix: make the identity merge auditable and stop a second delivery
Self-hosted installs run this unattended and it cannot be undone, so
each collapsed group now logs the workspace, the identity, which row was
kept, which were dropped, and how many post_platforms and automations it
touched. down() says plainly that it drops the index only.
Two narrower fixes:
A post holding a published row plus an enabled unpublished row for the
same account kept both, and PostPlatform::scopeEnabled() filters on
`enabled` alone with no status check - so a republish would deliver the
same content to that identity twice. Once a published row exists, every
unpublished repeat goes.
The automation dedupe ran on every automation in the workspace, not just
the ones the merge rewrote. A node legitimately holding two entries for
one account under different content types would be collapsed to
whichever came first in the array. It now runs only where an id was
actually substituted.
* test: rehearse the identity merge against a messy database
Every test on this migration so far covered a case someone thought to
write, which is why three separate review rounds each found a defect the
earlier ones missed. This builds a deliberately messy database instead -
three workspaces, four networks, one to three copies of each identity,
posts mixing published, pending and failed rows across the duplicates
with enabled flags varying, and automations referencing them in both the
current and legacy JSON shapes - then runs the real migration and
asserts what must be true afterwards rather than what happens to a
particular fixture.
Invariants: no duplicate identity survives, no published row is ever
destroyed, no post ends up enabled twice against one account, nothing in
post_platforms or automations points at a deleted account, and the
newest row of each identity is the one kept.
The generator is seeded, so a failure reproduces, and it asserts its own
output is adversarial - roughly nine duplicate groups and fourteen
published rows - so it cannot quietly degrade into passing on an empty
problem. Verified by mutation: dropping the automation repoint, the
published guard, or the repeated-target collapse each fails exactly the
invariant that covers it.
* fix: stop the youtube picker refetching itself into a cleared session
HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding
- exactly the people connecting their first accounts - so Inertia
re-GETs the picker route right after it mounts. For Facebook and
Instagram that re-entry is harmless and deliberately left deferred, but
YouTube calls the Google API again, and fetchChannels() turns any
failure into an empty list that clears the connect session and swaps the
mounted picker for an error the user cannot retry from. Same guard the
LinkedIn picker already got.
LinkedIn also answered a reconnect that authorized a different identity
with "Page not found", including in the person branch where no page is
involved. Every other platform says wrong_account, which this PR added.
* refactor: drop the unreachable youtube channel picker
Google's own delegation screen already lists every channel on the
account and makes the user pick one before it issues the token, so
channels?mine=true always answers with that single channel and
count($channels) === 1 always won. The picker behind it was never
reached - its Vue page was deleted back in 7c00c338 (January) and
nothing broke, which is the clearest evidence it was dead.
Removes selectChannel(), select(), both routes, the youtube_oauth
session payload and the tests that drove them. If Google ever does
return more than one, the callback connects the first and logs a warning
rather than routing to a screen that no longer exists.
* fix: serialize connects so two popups cannot seat one network twice
The observer's occupiesNetwork() is a check-then-insert with nothing
holding the gap, and the new unique index covers the identity, not the
network. Two tabs finishing OAuth at the same moment for *different*
identities on one network both passed the exists() check and both
inserted, leaving a Cloud workspace with the two accounts the rule
exists to prevent. The same-identity race was already safe - the unique
violation is caught and re-queried.
A database constraint cannot hold this: allow_multiple_social_accounts
is a runtime flag, so the rule is on for Cloud and off for self-hosted,
and an index cannot read config. Lock per workspace and network instead,
the way markAsDisconnected() and ConnectionVerifier already do.
This covers connectIdentity(), which every OAuth flow and the Telegram
action go through. A direct create() still answers to the observer
alone, and a self-hosted install running file cache across several nodes
locks per node.
* fix: handle a busy connect lock on the telegram path
Every other caller funnels LockTimeoutException into its generic
\Exception catch and closes the popup with error_connecting. Telegram
has no such catch, so the new lock could 500 the webhook - and because
the nonce is spent before connectIdentity runs, Telegram's retry of the
same update short-circuits on the consumed code and returns without
dispatching anything. The dialog would spin forever on a code that can
no longer be used.
Also restores coverage the picker removal dropped: the deleted select
tests were the only ones driving a multi-channel response, so nothing
exercised the reconnect narrowing to its own card, or multi-account mode
skipping an already-connected channel. Both are back against the
callback, and removing the narrowing in filterConnectableIdentities
fails them.
* fix: stop the instagram login seating an account already held via facebook
filterConnectableIdentities() drops every identity already connected on the
network, which is what keeps one Instagram account from being seated twice
under its two platforms. Every flow that persists an identity ran it except
the direct Instagram Login callback, so the guard only held in one direction:
InstagramFacebookController refused an account already connected as
`instagram`, but the reverse was allowed through.
With multiple accounts per network enabled the observer's network check is
bypassed and the unique index does not span platforms, so authorizing the
same account through the direct flow created a second row. Both then seed a
post_platform row and the post goes out twice to one account.
* fix: name the real reason when a linkedin profile reconnect switches member
Reconnecting a card narrows the authorized identities to that card's own, so
authorizing a different LinkedIn login empties the pool. selectIdentity()
reported that as "Page not found." for every card, including personal
profiles where no page was ever involved.
A profile reconnect has no page to be missing: an empty pool there can only
mean this login is a different member. Say so with the wrong_account wording
select() already uses for the same condition. Page reconnects keep
page_not_found, where the organization really can be absent from the login.
* fix: surface the busy telegram connect instead of a generic failure
The connect lock timing out dispatches its own 'busy' reason so the dialog
can tell the user to retry, but the dialog only mapped network_taken and
wrong_chat and fell back to error_generic for everything else. The reason
reached the browser and died there, leaving "Could not start the connection"
for a case that just needs another moment.
* test: cover reconnect on every flow that gained it
rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and
Bluesky a reconnect path they did not have before — TikTok had been actively
clearing social_reconnect_id on connect — and none of them had a test for it.
Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did.
Each now covers both halves: authorizing the same identity refreshes the
existing card and reports it as a reconnect, and authorizing a different one
is refused with wrong_account instead of quietly seating a stranger on the
card and every post scheduled against it.
* fix: repair what a reconnect leaves behind when it cannot proceed cleanly
Two things connectIdentity got wrong once the reconnect path existed.
A reconnect through the other variant of a network moves the card to the new
platform — same identity, different API flavor. Post targets carry their own
platform snapshot, and that snapshot picks the publisher, the queue and the
scopes checked before publishing. Left behind, it failed every pending post on
a permission the account no longer needs: an Instagram card moved to the
Facebook variant still demanded instagram_business_content_publish and stopped
with "Missing permissions". Pending targets now follow the card and reset a
content type the new platform cannot publish; published targets keep theirs,
since they record what really went out under a platform_post_id from that API.
The network lock timing out also arrived as a raw LockTimeoutException, which
every OAuth callback filed through its generic catch: an error log and "Error
connecting account" for the exact race the lock exists to absorb. It now
carries a busy messageKey through the branch each flow already handles, the
same way the Telegram path already reported it.
* refactor: resolve the linkedin reconnect card once per select
select() already looked the card up before deciding whether the chosen
identity matches it, then connectPerson() and connectOrganization() looked it
up again on their own — two identical queries per submit, and two places that
could disagree about what is being reconnected. The caller passes what it
already holds.
* test: render the grid's multi-account branch
phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test
overrode it, so the card the flag exists to add never rendered anywhere. The
pair pins both sides: a taken network offers no second card when multiples are
off, and offers one when they are on.
* test: pin why the linkedin select guards exist
connectIdentity() already refuses a mismatched reconnect and answers with the
same wrong_account message, so every existing test passes with the two guards
in select() deleted — which is exactly how they would get deleted. What they
actually buy is skipping the avatar download that building the connect payload
runs first.
Both now assert the fetch never happens, so the guards fail loudly instead of
looking redundant.
* fix: carry retrying targets through a variant move, atomically
Two holes in the move added a commit ago.
It only carried pending targets, but a retrying one is not finished either —
the publish job reschedules itself and reads the snapshot fresh on the next
attempt, so leaving it behind meant it retried against the old variant until
it exhausted its budget on a permission the account no longer needs. Failed
and published targets stay put; a publishing one has a job mid-flight already
working from the snapshot it read.
The card and its targets also moved in three separate statements, so a crash
between them left exactly the split this was meant to close. They share a
transaction now.
* chore: drop the dusk selectors nothing reads
Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase,
no browse(). Browser tests run on pest-plugin-browser driving Playwright, and
its @selector resolves to data-testid. The 45 dusk attributes left across 18
components selected nothing.
CLAUDE.md was the reason they kept coming back: it told every agent to add
them. Its browser-testing section now describes the setup that exists —
data-testid targeting, the wait helper these tests need because assertions do
not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real.
Verified before removing: every @selector used in tests/Browser resolves to a
data-testid, seven of them through bound :data-testid, so none depended on a
dusk attribute.
* chore: drop the last one-account-per-network helper
hasConnectedPlatform() has no callers left anywhere — app, tests, views or
routes. It sat directly above getSocialAccount(), which this branch already
removed, and is the same leftover from when a workspace could hold one account
per platform.
---------
Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
## Multiple social accounts per network
One connected identity per social network per workspace is the Cloud default. This is **not** tied to `SELF_HOSTED` — Cloud cannot flip that flag, but it can flip this one.
| Env | Config | Default | Effect |
| --- | --- | --- | --- |
| `ALLOW_MULTIPLE_SOCIAL_ACCOUNTS` | `trypost.allow_multiple_social_accounts` | `false` (falls back to `SELF_HOSTED` when unset) | `true` : a workspace may connect more than one account of the same network (two LinkedIns, two Instagrams, …). `false` : one per network (LinkedIn profile + page count as one; Instagram standalone + Instagram-via-Facebook count as one). Reconnecting the same `platform` + `platform_user_id` still updates the existing row. Shared to Inertia as `allowMultipleSocialAccounts` . |
Self-hosted compose / `.env.example` set this `true` . When the env is unset, the config falls back to `SELF_HOSTED` so existing self-hosted installs keep multiple accounts. Do **not** use `selfHosted` for the occupancy check (observer, Telegram connect, `NetworkConnectGrid` ).
2026-03-29 21:10:10 +00:00
## Icons (@tabler/icons-vue)
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
- This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next` .
- All Tabler icons are prefixed with `Icon` , e.g. `IconCheck` , `IconChevronRight` , `IconMail` .
- Import icons from `@tabler/icons-vue` : `import { IconCheck, IconX } from '@tabler/icons-vue'` .
- Browse available icons at https://tabler.io/icons
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
## Dates
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
- For date manipulation, always use `@/dayjs` (pre-configured dayjs instance with utc, timezone, relativeTime plugins).
- For formatting dates for display (formatDate, formatDateTime, formatTime, diffForHumans), always use `@/date` which centralizes all formatting logic with proper timezone handling.
- Never use raw `new Date()` for date calculations — use dayjs.
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
## Routing (Wayfinder)
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
- This project uses Laravel Wayfinder for type-safe frontend routing.
- ALWAYS use Wayfinder-generated route helpers in Vue pages (e.g. `register()` , `login()` , `dashboard()` ). NEVER hardcode URL strings like `href="/register"` .
- After creating or modifying PHP routes/controllers, run `php artisan wayfinder:generate` to regenerate the TypeScript route helpers.
- Import routes from `@/routes/...` (e.g. `import { store } from '@/routes/login'` ).
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
## Pagination
2026-01-15 01:13:44 +00:00
2026-03-29 21:10:10 +00:00
- Always use normal pagination (`->paginate()`). NEVER use cursor pagination (`->cursorPaginate()`).
- All paginated lists must use Inertia's scroll pagination (`Inertia::scroll()` on the backend with `<InfiniteScroll>` on the frontend). NEVER use traditional page-based pagination with page links/buttons.
2026-06-13 16:59:52 +00:00
- The page size ALWAYS comes from `config('app.pagination.default')` — never a magic number, and never a `perPage` /`per_page` value supplied by the request or frontend. Action/service list methods must NOT accept a `$perPage` parameter; call `->paginate((int) config('app.pagination.default'))` directly.
- The only exception is the public REST API (`app/Http/Controllers/Api`), which uses its own fixed, documented page size (15) as a stable API contract.
2026-03-29 21:10:10 +00:00
## Form Validation
- NEVER use HTML5 validation attributes (`required`, `minlength` , `pattern` , etc.) on form inputs. Always rely solely on backend validation.
2026-05-06 20:21:30 +00:00
## Backend Validation
- Validation rules always live in a dedicated `Illuminate\Foundation\Http\FormRequest` subclass under `app/Http/Requests/App/<Group>/` . Controller actions must type-hint the FormRequest as the parameter — NEVER call `$request->validate([...])` inline in the controller.
Remove the post-templates feature (#296)
* Remove the post-templates feature
Removes the browsable post-templates catalog end to end: routes
(app.post-templates.index/apply), controller, form requests, resource,
Registry/PostTemplateData/TemplateNotFoundException services, the
templates:report console command, the templates/ catalog (36 files
across en/es/pt-BR), the templates Index.vue page, the template card
on the post Create.vue choice screen, and its i18n keys across all 16
locales.
Also removes TemplateContextResolver, which fed catalog examples into
PostContentGenerator's AI prompt. The "examples" block (with its
heading) is removed from generator.blade.php rather than left empty;
AI generation no longer references the templates catalog.
The AI content-template system under app/Ai/Templates (image/tweet
card templates for the AI post wizard) and TemplateImageGenerator are
unrelated and untouched.
* Update FormRequest naming examples after the post-templates removal
CLAUDE.md and the Cursor rules cited ApplyPostTemplateRequest and
IndexPostTemplateRequest as naming examples. Both classes were deleted
with the feature, so the guidance pointed at files that no longer exist.
* Share the AiTemplate type between the create screen and the AI wizard
Create.vue and AiPostWizard.vue each declared their own AiTemplate
interface, and they had drifted: the wizard's carried
applies_brand_visuals, the create screen's did not. TypeScript treated
them as unrelated types with the same name, which surfaced as a TS2719
error where the templates prop is passed between them.
The backend sends seven fields (PostController::create), so the shared
declaration follows the payload rather than the union of the two copies.
2026-08-19 12:34:26 +00:00
- Naming: `<Verb><Resource>Request.php` (e.g. `StorePostRequest` , `UpdatePostRequest` , `LinkPreviewRequest` ).
2026-05-06 20:21:30 +00:00
Make the test suite pass on MySQL (#307)
* Give the foreign key a backing index before dropping the unique
social_accounts.workspace_id carries a foreign key, and the composite
unique index is the only one covering it, as its leftmost prefix. MySQL
refuses to drop the sole index backing a foreign key (SQLSTATE[HY000]
1553), so both rehearsal suites failed in beforeEach and never ran a
single assertion on MySQL. Add a plain index on workspace_id first;
PostgreSQL has no such requirement and simply carries it.
This unmasks one assertion underneath that had never executed: the
automation graph comparison at DuplicateIdentityMigrationTest.php:419
depended on JSON object key order, which MySQL normalises on storage.
(cherry picked from commit 98a494bd2205e873321a18232f63b358ae259fdf)
* Compare JSON payloads without depending on key order
MySQL normalises JSON object keys (length, then lexicographic) on
storage, so an identity comparison against a literal asserts how the
driver chose to lay the object out rather than what it contains.
PostgreSQL preserves insertion order, which is why these passed there.
toEqual compares associative arrays recursively without regard to key
order. Applied to every assertion in this class, including the few that
pass today only because their keys already happen to match MySQL's
ordering.
(cherry picked from commit 3124023c548d6c2b8b52126afc6fc5f38d461ea6)
* Match logged SQL without depending on identifier quoting
Four DB::listen predicates matched 'select * from "post_platforms"'.
PostgreSQL quotes identifiers with double quotes and MySQL with
backticks, so on MySQL the predicates never matched, the simulated
mid-run pause never fired, and the race these tests exist to cover went
unexercised while the tests still reported failures elsewhere.
Compare against the unquoted form via a small helper.
(cherry picked from commit 67a81df5de155e80227df748b34cd8b3cfd744f9)
* Cast raw boolean reads in tests so they pass on MySQL
Three assertions read oauth_refresh_tokens.revoked through the query
builder rather than Eloquent, so no cast applies and the driver's native
representation leaks into the test: a real boolean on PostgreSQL, 1 on
MySQL. Cast explicitly at the call site.
(cherry picked from commit 2911c5c48cf65d24a34a41e667335c40005839a7)
* Use a scheduling date inside MySQL's TIMESTAMP range
MySQL TIMESTAMP columns end at 2038-01-19, so the 2099 sentinel these
tests used is rejected outright with SQLSTATE[22007]. 2037-12-31 still
reads as a far-future schedule and works on both engines.
(cherry picked from commit bde33eb239cdbd3a5567d4c21e1d85302913cdd7)
* Remove the duplicate-identity migration scenario test
The suite rebuilt a pre-migration schema by dropping the unique index in
beforeEach and re-running the migration by hand, exercising a database
state the application never runs in.
* Fix the MySQL rollback path and run CI on both engines
The migration's down() dropped a unique whose leftmost prefix is an FK
column, which MySQL refuses when nothing else backs the constraint
(SQLSTATE 1553). It now creates a standalone index first, so
migrate:rollback works on MySQL and stays a no-op change for PostgreSQL.
up() is untouched: every database already migrated keeps its schema.
The rehearsal test calls that down() instead of hand-rolling the drop,
so it exercises the real rollback rather than an imitation of it.
Matches logged SQL through the connection's query grammar rather than
stripping quote characters, and adds a MySQL leg to the backend CI job.
* Use a readiness check both database images can run
mysql:8.4 installs mysql-community-server-minimal, which ships neither
mysqladmin nor the mysql client, so a mysqladmin health command never
succeeds and the service never reports healthy. Both images run their
init phase without networking, so an open port is the point either
engine starts accepting connections - one check covers both, and the
per-engine matrix key goes away.
* Use each engine's own readiness tool
pg_isready and mysqladmin ping are what the respective images ship for
this, and the mysql image's entrypoint invokes mysqladmin itself, so it
is present. Keeps 20 retries, which MySQL needs to finish initialising.
* State the two-engine ceiling as a rule, not a test detail
The 2038 TIMESTAMP limit binds anything written to the column, not just
the sentinel dates in fixtures, and the same reasoning generalises: what
the app supports is the intersection of both engines.
* Let the release image connect to MySQL
The published image installed only pdo_pgsql, so DB_CONNECTION=mysql
failed with "could not find driver" before any query ran - the app
supports MySQL but the image people actually deploy could not reach it.
mysql-client mirrors the postgresql-client already present, for
artisan db and dumps.
* Keep "backend" a single required status check
Matrixing the job split its check in two, so the "backend" context the
branch protection requires was never reported and every PR sat waiting
on it. The matrix is now "tests" and a small "backend" job gates on it,
which keeps the required check stable however many engines the matrix
grows to - and leaves the open PRs mergeable without a rebase.
---------
Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-08-29 14:05:33 +00:00
## Database engines (PostgreSQL + MySQL)
TryPost runs on **both PostgreSQL and MySQL** . Cloud runs PostgreSQL; a self-hosted install may pick either. Every query, migration, and test must work on both — the suite is expected to be green on each.
- **What the app supports is the intersection of the two engines, never the superset of one.** When they differ, take the narrower behaviour — a feature that only holds on PostgreSQL is a feature TryPost does not have.
- Never use an engine-specific operator or function. Search uses `whereLike()` (Laravel handles the case-insensitive form per driver), never `ilike` or a raw `LOWER(...)` comparison.
- Traps that only surface on MySQL:
- **JSON object key order is not preserved.** MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with `toEqual` (recursive, order-independent), never `toBe` /`assertSame`. Array *element* order is preserved on both.
- **`$table->timestamp()` tops out at 2038-01-19.** PostgreSQL has no such limit, so 2038-01-19 is the app's ceiling: nothing written to a `timestamp()` column may go past it — scheduled posts, expiry sentinels and test fixtures alike. `2037-12-31` reads as "far future" and works on both. Do not widen a column to escape the limit without a deliberate decision; it changes what self-hosted MySQL installs can store.
- **Raw query-builder reads carry no Eloquent cast**, so the driver's native shape leaks through: `DB::table(...)->value('some_bool')` is `true` on PostgreSQL and `1` on MySQL. Read through the model, or use `assertDatabaseHas` .
- **Identifier quoting differs** — PostgreSQL emits `"post_platforms"` , MySQL emits backticks. Never match logged SQL (`DB::listen`) against a quoted identifier.
- **MySQL refuses to drop the only index backing a foreign key** (SQLSTATE `1553` ). A migration `down()` that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first.
- **DDL implicitly commits**, which defeats `RefreshDatabase` 's rollback: schema changes made inside a test leak into the tests that follow. Keep them idempotent.
2026-06-16 20:52:50 +00:00
## Per-Platform Post Meta (`PostPlatform.meta`)
- All `platforms.*.meta` validation (the parent array rule AND every per-platform sub-key: `aspect_ratio` , TikTok `privacy_level` /flags, Pinterest `board_id` , Discord `channel_id` /`mentions`/`embeds`, etc.) lives in ONE place: `App\Support\PostPlatformMetaRules` .
- Every post create/update entry point — web (`App\Http\Requests\App\Post\UpdatePostRequest`), public API (`App\Http\Requests\Api\Post\{Store,Update}PostRequest`), and MCP (`App\Mcp\Tools\Post\{Create,Update}PostTool`) — spreads `...PostPlatformMetaRules::rules()` . NEVER add a per-platform meta rule inline to a single request/tool.
- Why: `FormRequest::validated()` (and MCP `$request->validate()` ) STRIPS any key without a rule. A meta field defined in only one entry point is silently dropped everywhere else — which is exactly how Discord/Pinterest/TikTok meta was lost via API/MCP before this was centralized.
- Required-on-publish (meta a platform needs to publish, e.g. Discord `channel_id` ) also lives there: `addRequiredOnPublishErrors()` for request-driven flows (web/API update `withValidator` ), `assertStoredPostPublishable()` for flows that publish stored state without resubmitting platforms (MCP `PublishPostTool` ). Add new required-meta rules to `requiredMetaViolation()` , not inline.
- When adding a new platform's meta field, add it (and any publish requirement) to `PostPlatformMetaRules` ONLY, and cover it in `tests/Feature/Api/PostApiPlatformMetaTest.php` + `tests/Feature/Mcp/PostPlatformMetaToolTest.php` .
2026-06-25 21:29:10 +00:00
## Media Types (image / video / document)
- A media item is one of exactly three types: **image** , **video** , **document** (PDF). There is no standalone "audio" media type (audio exists only as a video voiceover input).
- Media-type detection lives in ONE place per side — NEVER hand-write `type === 'image'` , `mime_type === 'application/pdf'` , `mime.startsWith('video/')` , or extension checks inline.
- Backend: `App\Enums\Media\Type` — `classify()` , `fromMime()` , `fromExtension()` , `isGif()` , plus the `allowedMimeTypes()` / `extensions()` allow-lists. Use these, never a raw MIME/extension comparison.
- Frontend: `resources/js/lib/mediaType.ts` — the mirror of the backend enum: the `MediaType` union, `classify()` , `fromMimeType()` (for a browser `File.type` ), `fromExtension()` , `isImage()` /`isVideo()`/`isDocument()`/`isGif()`. `@/composables/useMedia` re-exports `isImageMedia` /`isVideoMedia`/`isDocumentMedia` aliases for legacy call sites.
- Detection trusts the explicit `type` first, then the MIME, then the filename extension — so an item with only a MIME (e.g. AI/Unsplash/Giphy media without a `type` ) still classifies correctly. A bare `item.type === 'image'` (with a `v-else` video) silently mis-renders those.
- The `type` field on every media-ish interface is the `MediaType` union, never `string` — `MediaItem` , and any sibling picked/asset/saved shape (`PickedMedia`, `AssetMedia` , `SavedMedia` , etc.).
- The upload `accept` attribute for "everything we allow" comes from `acceptAttribute()` (frontend) / `Media\Type::allowedMimeTypes()` (backend) — never a hardcoded MIME list. Per-capability `accept` builders driven by content-type rules (e.g. `image/*,video/*` ) are fine; those aren't detection.
2026-05-06 20:21:30 +00:00
## Pest / Feature Tests
- ALWAYS use named routes via the `route()` helper in feature tests. NEVER hardcode URL strings like `'/posts/ai/create'` .
- Example: `$this->postJson(route('app.posts.store'))` instead of `$this->postJson('/posts')` .
- With params: `route('app.posts.ai.create.finalize', $creationId)` .
Allow multiple social accounts per network via env (#286)
* feat: expose self-hosted mode to the accounts UI
SocialAccountObserver already bypasses the one-account-per-network
guard when trypost.self_hosted is true, but the frontend had no way
to know that and always collapsed a network to a single card once
any account existed - so self-hosted deployments could not surface
a second LinkedIn (or Instagram) connection even though the backend
would allow creating it.
* feat: allow connecting multiple accounts per network when self-hosted
NetworkConnectGrid always collapsed a network (LinkedIn profile/page,
Instagram standalone/Facebook) to a single card once any account
existed, with no way to trigger another OAuth flow - even though
SocialAccountObserver already allows unlimited accounts per network
in self-hosted mode. A self-hoster connecting their personal LinkedIn
profile had no path back to the connect flow to also add a company
page (or a second company page/showcase page).
Render one card per connected account instead of collapsing to the
first, and keep a standing "Connect another" card available for a
network's existing connections when self-hosted. Hosted mode is
unchanged: still one card per network, matching the backend's
still-enforced one-account-per-network limit there.
* test: cover the selfHosted prop on accounts and onboarding pages
Backend behavior for connecting a second identity per network in
self-hosted mode was already covered (LinkedInControllerTest,
NetworkUniquenessTest) - these just confirm the new prop the frontend
now depends on is actually present and reflects config correctly.
* style: apply prettier formatting
Pre-existing drift in this file unrelated to the selfHosted change.
* refactor: read selfHosted from shared Inertia props
The flag is already shared by HandleInertiaRequests, so the accounts
and onboarding controllers do not need to pass it again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: gate multiple social accounts with a dedicated env
Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: tighten multiple-account gates after review
Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: bind reconnect to the card and unique social identity
Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: build the OAuth URL before opening the popup
Keep the popup opener URL-only so reconnect query params are assembled at the call site.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: drop dead social-account guards and slim the connect grid
Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: scope social reconnect to the current network
Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: slim connectable-identity filtering
Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: slim social identity persist helpers
Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: keep reconnect updates on the original social card
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: run the suite with multiple social accounts enabled
phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the
true value in .env.ci. That broke eight tests across Automation, MCP,
PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only
needed two accounts of one network as a fixture, not as a rule under test.
Match .env.ci instead. Every test that exercises the one-per-network rule
already sets the config itself; the accounts index test was the only one
leaning on the implicit default, so it now pins it.
* fix: align the multi-account fallback with the self-hosted default
allow_multiple_social_accounts fell back to env('SELF_HOSTED', false)
while self_hosted itself defaults to env('SELF_HOSTED', true). A
self-hosted install that never wrote SELF_HOSTED to its .env resolved to
false and silently lost multiple accounts per network on upgrade, which
is the opposite of what the documented fallback promises.
* fix: collapse duplicate identities before adding the unique index
Installs predating the index can hold the same identity twice: the
network guard was bypassed for multi-account installs and Pinterest
always created a fresh row. Creating the index on that data aborts
migrate mid-deploy.
Keep the newest row per identity and move its post_platforms over before
dropping the duplicates - the FK is nullOnDelete, so deleting outright
would orphan drafts and scheduled posts.
* fix: refuse a reconnect that authorized a different identity
connectIdentity overwrote platform_user_id with whatever the provider
returned, so reconnecting a card while signed into another account
repointed the row - and every draft and scheduled post bound to it - at
a stranger. LinkedIn guarded this at the controller and Facebook via its
filtered page list; nothing covered X, TikTok, Threads, Discord,
Bluesky, Mastodon, Pinterest, Instagram or Telegram.
Enforce the identity match at the single choke point every connect flow
goes through. Every call site already maps NetworkAlreadyConnectedException
to network_taken, so the refusal surfaces without new plumbing.
Also restore the null-platform guard in the observer: occupiesNetwork
type-hints a non-nullable Platform, so a row without one died with a
TypeError instead of the database's NOT NULL error.
* fix: filter connectable identities on every picker step
YouTubeController::select re-fetched the channels and matched the posted
id straight off the raw list, unlike callback and selectChannel. With a
live youtube_oauth session it let a POST name any channel the Google
account owns and bind it to the reconnect target. It also read the
reconnect from the session while the connect below it read
youtube_oauth.reconnect_id, so the two could disagree - pass the
resolved account through instead.
filterConnectableIdentities also short-circuited in multi-account mode,
and the unique index is scoped to platform rather than network. That let
one Instagram account connect twice, once directly and once via
Facebook, publishing every Instagram post to it twice. The existing
except() already spans networkPlatformValues(), so dropping the
short-circuit closes it.
* refactor: type the connect cards and drop the dead accounts grid
The cards computed inferred account as a required ConnectedAccount and
then pushed undefined onto it (TS2345). CI only runs eslint so it stayed
green, but vue-tsc and editors flag it.
SocialAccountsGrid is referenced nowhere; its reconnect button was
updated in this branch without passing the card id, which would have
been a bug had anything rendered it.
* test: keep the suite on the cloud one-account-per-network default
CI runs the Cloud build, so the suite baseline should be the Cloud
default rather than the self-hosted one. Put phpunit.xml and .env.ci
back to false and make the eight tests that merely need two accounts of
one network as a fixture opt in for themselves.
This also un-deads the config()->set(true) calls the branch had already
added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and
SocialAccountObserverTest, which the forced true had turned into no-ops.
* fix: connect standalone instagram instead of reopening the picker
The picker emits an already-resolved connect method, but this branch
rewired @select from openOAuthPopup to startConnect. startConnect sends
a bare 'instagram' straight back into its own picker branch, so choosing
"Instagram" closed the dialog and immediately reopened it - the OAuth
window never opened and the standalone flow was unreachable. Only the
via-Facebook button still worked.
Split the URL-opening tail out of startConnect and let the dialog call
that directly.
* fix: reject a telegram reconnect before burning the connect code
The nonce was consumed before connectIdentity ran, so posting /connect in
the wrong chat spent the one-off code and forced the user to generate a
new one. Check the identity first and report wrong_chat instead of
network_taken, which told them to disconnect an account when the real fix
was posting in the channel they were reconnecting.
* fix: leave one target per post when merging duplicate accounts
post_platforms has no unique on (post_id, social_account_id), so a post
holding a row per duplicate account ended up with two enabled rows aimed
at the surviving account and would publish to it twice. Keep one row per
post, preferring a published one so history survives.
* refactor: collapse the repeated connect-flow boilerplate
Four shapes were copy-pasted across the connect controllers:
- the session + permission guard opening 16 actions, now connectWorkspace()
throwing a ConnectPopupException that renders the popup itself
- the reconnected/connected ternary in 13 places, now connectedCallback()
- the "nothing left to connect" branch in 4 places, now
noConnectableIdentities()
- validatedReconnectId() re-querying what reconnectAccount() already does
Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect
account three or four times per callback; it is resolved once and passed
down. The three GET pickers skipped the manageAccounts check their POST
siblings had, and pick it up from the shared guard.
Drops the color key from connectableOptions and the matching frontend
field - nothing read it. Platform::color() stays; the disconnection
emails use it.
* fix: keep an expired connect popup out of the error log
ConnectPopupException escapes to the framework handler so it can render
itself, which also meant report() ran first: every session_expired and
workspace_not_found popup filed an ERROR and a Nightwatch issue for what
used to be a silent return. A stale popup is a normal outcome, so it now
implements ShouldntReport.
The Mastodon and Threads guards also cleared their provider session
after connectWorkspace(), so a workspace that vanished mid-flow left the
client secret and the OAuth state behind. Clear first, then resolve.
clearMastodonSession() no longer touches social_connect_workspace -
whatever closes the popup already does.
* fix: stop telling users to disconnect an account that is not the problem
Two flows reused popup_callback.network_taken - "This workspace already
has an account for this network. Disconnect it first." - for situations
where that is neither true nor actionable:
- reconnecting a card while signed into a different account on the
provider, now wrong_account
- an empty picker in multi-account mode, where every page or channel on
that login is simply already connected, now all_connected
NetworkAlreadyConnectedException carries the message key so the catch
sites stay one line. handleCallback() also drops its $platform argument;
it read $this->platform for the reconnect lookup and the identity filter
either way, so a caller passing a different platform would have scoped
the lookup to the wrong network.
* refactor: filter linkedin identities with the shared helper
The picker hand-rolled its own reconnect narrowing because the profile
and the pages arrive in two different shapes. Flatten them into one pool
of LinkedIn identities, run the shared filter, and split them again for
the view - the same path Facebook, YouTube and Instagram already take.
Side effect worth having: the picker previously only narrowed on a
reconnect, so it would offer an identity that is already connected and
only fail once the user picked it. It now hides taken identities up
front and says so when nothing is left.
* fix: keep the linkedin picker's own empty state
Routing the picker through the shared filter made every empty pool look
like "nothing left to take", including the pool LinkedIn never filled.
A self-hoster running pages-only who administers no page was told the
network was already connected, or that every account on the login was
taken - both false - and the picker's own "you are not an admin of any
LinkedIn page" state became unreachable.
Only treat it as taken when filtering is what emptied it. Splitting the
pool back also compared the person id loosely on one side and strictly
on the other; one predicate now drives both.
Threads had two forget() calls for a key the top of the action already
clears, and YouTube's picker resolved the reconnect account twice on the
failure path.
* fix: keep the enabled row when collapsing duplicate post targets
SyncPostPlatforms seeds a disabled post_platforms row for every account
in the workspace, so the usual duplicate is one row the user actually
checked next to one they never saw - both pending, both created in the
same second. Ordering only by published-then-newest made that a coin
flip, and PublishPost iterates enabled() only, so half the time a
scheduled post would silently stop reaching that account and take its
caption and per-platform meta with it. This runs once against production
data and the dropped row is gone, so enabled now beats disabled.
Also: the empty-pool exit from the LinkedIn picker was the only one
leaving linkedin_pending - and its tokens - in the session. The
rationale comments move to the docblocks they belong in, and usePage()
comes out of the cards computed.
* fix: stop the migration destroying publish history and automations
Two ways the one-shot merge lost data that cannot be rebuilt:
Surplus published post_platforms rows were deleted. Two duplicate
accounts really could each have published, and each row carries the
platform_post_id for a live post on the network - dropping one leaves
that post unmanageable and invisible to metrics. The docblock claimed
published beat everything; now the code does, and only unpublished
repeats collapse.
Automation nodes persist social_account_id inside a JSON column with no
foreign key, so deleting the loser left RunGenerateNode skipping that
target, or generating nothing at all when it was the node's only
account. The ids are rewritten - current and legacy shapes both - and
entries the merge just turned into duplicates are collapsed.
Ordering is now total (null created_at sorts oldest on every engine,
then id) so a rehearsal on a replica keeps the same rows as the real
run. The LinkedIn picker also passes onboardingProgress inline: it
clears linkedin_pending on the empty path, and a deferred reload would
re-GET the route and swap the empty state for a session-expired popup.
* fix: make the identity merge auditable and stop a second delivery
Self-hosted installs run this unattended and it cannot be undone, so
each collapsed group now logs the workspace, the identity, which row was
kept, which were dropped, and how many post_platforms and automations it
touched. down() says plainly that it drops the index only.
Two narrower fixes:
A post holding a published row plus an enabled unpublished row for the
same account kept both, and PostPlatform::scopeEnabled() filters on
`enabled` alone with no status check - so a republish would deliver the
same content to that identity twice. Once a published row exists, every
unpublished repeat goes.
The automation dedupe ran on every automation in the workspace, not just
the ones the merge rewrote. A node legitimately holding two entries for
one account under different content types would be collapsed to
whichever came first in the array. It now runs only where an id was
actually substituted.
* test: rehearse the identity merge against a messy database
Every test on this migration so far covered a case someone thought to
write, which is why three separate review rounds each found a defect the
earlier ones missed. This builds a deliberately messy database instead -
three workspaces, four networks, one to three copies of each identity,
posts mixing published, pending and failed rows across the duplicates
with enabled flags varying, and automations referencing them in both the
current and legacy JSON shapes - then runs the real migration and
asserts what must be true afterwards rather than what happens to a
particular fixture.
Invariants: no duplicate identity survives, no published row is ever
destroyed, no post ends up enabled twice against one account, nothing in
post_platforms or automations points at a deleted account, and the
newest row of each identity is the one kept.
The generator is seeded, so a failure reproduces, and it asserts its own
output is adversarial - roughly nine duplicate groups and fourteen
published rows - so it cannot quietly degrade into passing on an empty
problem. Verified by mutation: dropping the automation repoint, the
published guard, or the repeated-target collapse each fails exactly the
invariant that covers it.
* fix: stop the youtube picker refetching itself into a cleared session
HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding
- exactly the people connecting their first accounts - so Inertia
re-GETs the picker route right after it mounts. For Facebook and
Instagram that re-entry is harmless and deliberately left deferred, but
YouTube calls the Google API again, and fetchChannels() turns any
failure into an empty list that clears the connect session and swaps the
mounted picker for an error the user cannot retry from. Same guard the
LinkedIn picker already got.
LinkedIn also answered a reconnect that authorized a different identity
with "Page not found", including in the person branch where no page is
involved. Every other platform says wrong_account, which this PR added.
* refactor: drop the unreachable youtube channel picker
Google's own delegation screen already lists every channel on the
account and makes the user pick one before it issues the token, so
channels?mine=true always answers with that single channel and
count($channels) === 1 always won. The picker behind it was never
reached - its Vue page was deleted back in 7c00c338 (January) and
nothing broke, which is the clearest evidence it was dead.
Removes selectChannel(), select(), both routes, the youtube_oauth
session payload and the tests that drove them. If Google ever does
return more than one, the callback connects the first and logs a warning
rather than routing to a screen that no longer exists.
* fix: serialize connects so two popups cannot seat one network twice
The observer's occupiesNetwork() is a check-then-insert with nothing
holding the gap, and the new unique index covers the identity, not the
network. Two tabs finishing OAuth at the same moment for *different*
identities on one network both passed the exists() check and both
inserted, leaving a Cloud workspace with the two accounts the rule
exists to prevent. The same-identity race was already safe - the unique
violation is caught and re-queried.
A database constraint cannot hold this: allow_multiple_social_accounts
is a runtime flag, so the rule is on for Cloud and off for self-hosted,
and an index cannot read config. Lock per workspace and network instead,
the way markAsDisconnected() and ConnectionVerifier already do.
This covers connectIdentity(), which every OAuth flow and the Telegram
action go through. A direct create() still answers to the observer
alone, and a self-hosted install running file cache across several nodes
locks per node.
* fix: handle a busy connect lock on the telegram path
Every other caller funnels LockTimeoutException into its generic
\Exception catch and closes the popup with error_connecting. Telegram
has no such catch, so the new lock could 500 the webhook - and because
the nonce is spent before connectIdentity runs, Telegram's retry of the
same update short-circuits on the consumed code and returns without
dispatching anything. The dialog would spin forever on a code that can
no longer be used.
Also restores coverage the picker removal dropped: the deleted select
tests were the only ones driving a multi-channel response, so nothing
exercised the reconnect narrowing to its own card, or multi-account mode
skipping an already-connected channel. Both are back against the
callback, and removing the narrowing in filterConnectableIdentities
fails them.
* fix: stop the instagram login seating an account already held via facebook
filterConnectableIdentities() drops every identity already connected on the
network, which is what keeps one Instagram account from being seated twice
under its two platforms. Every flow that persists an identity ran it except
the direct Instagram Login callback, so the guard only held in one direction:
InstagramFacebookController refused an account already connected as
`instagram`, but the reverse was allowed through.
With multiple accounts per network enabled the observer's network check is
bypassed and the unique index does not span platforms, so authorizing the
same account through the direct flow created a second row. Both then seed a
post_platform row and the post goes out twice to one account.
* fix: name the real reason when a linkedin profile reconnect switches member
Reconnecting a card narrows the authorized identities to that card's own, so
authorizing a different LinkedIn login empties the pool. selectIdentity()
reported that as "Page not found." for every card, including personal
profiles where no page was ever involved.
A profile reconnect has no page to be missing: an empty pool there can only
mean this login is a different member. Say so with the wrong_account wording
select() already uses for the same condition. Page reconnects keep
page_not_found, where the organization really can be absent from the login.
* fix: surface the busy telegram connect instead of a generic failure
The connect lock timing out dispatches its own 'busy' reason so the dialog
can tell the user to retry, but the dialog only mapped network_taken and
wrong_chat and fell back to error_generic for everything else. The reason
reached the browser and died there, leaving "Could not start the connection"
for a case that just needs another moment.
* test: cover reconnect on every flow that gained it
rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and
Bluesky a reconnect path they did not have before — TikTok had been actively
clearing social_reconnect_id on connect — and none of them had a test for it.
Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did.
Each now covers both halves: authorizing the same identity refreshes the
existing card and reports it as a reconnect, and authorizing a different one
is refused with wrong_account instead of quietly seating a stranger on the
card and every post scheduled against it.
* fix: repair what a reconnect leaves behind when it cannot proceed cleanly
Two things connectIdentity got wrong once the reconnect path existed.
A reconnect through the other variant of a network moves the card to the new
platform — same identity, different API flavor. Post targets carry their own
platform snapshot, and that snapshot picks the publisher, the queue and the
scopes checked before publishing. Left behind, it failed every pending post on
a permission the account no longer needs: an Instagram card moved to the
Facebook variant still demanded instagram_business_content_publish and stopped
with "Missing permissions". Pending targets now follow the card and reset a
content type the new platform cannot publish; published targets keep theirs,
since they record what really went out under a platform_post_id from that API.
The network lock timing out also arrived as a raw LockTimeoutException, which
every OAuth callback filed through its generic catch: an error log and "Error
connecting account" for the exact race the lock exists to absorb. It now
carries a busy messageKey through the branch each flow already handles, the
same way the Telegram path already reported it.
* refactor: resolve the linkedin reconnect card once per select
select() already looked the card up before deciding whether the chosen
identity matches it, then connectPerson() and connectOrganization() looked it
up again on their own — two identical queries per submit, and two places that
could disagree about what is being reconnected. The caller passes what it
already holds.
* test: render the grid's multi-account branch
phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test
overrode it, so the card the flag exists to add never rendered anywhere. The
pair pins both sides: a taken network offers no second card when multiples are
off, and offers one when they are on.
* test: pin why the linkedin select guards exist
connectIdentity() already refuses a mismatched reconnect and answers with the
same wrong_account message, so every existing test passes with the two guards
in select() deleted — which is exactly how they would get deleted. What they
actually buy is skipping the avatar download that building the connect payload
runs first.
Both now assert the fetch never happens, so the guards fail loudly instead of
looking redundant.
* fix: carry retrying targets through a variant move, atomically
Two holes in the move added a commit ago.
It only carried pending targets, but a retrying one is not finished either —
the publish job reschedules itself and reads the snapshot fresh on the next
attempt, so leaving it behind meant it retried against the old variant until
it exhausted its budget on a permission the account no longer needs. Failed
and published targets stay put; a publishing one has a job mid-flight already
working from the snapshot it read.
The card and its targets also moved in three separate statements, so a crash
between them left exactly the split this was meant to close. They share a
transaction now.
* chore: drop the dusk selectors nothing reads
Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase,
no browse(). Browser tests run on pest-plugin-browser driving Playwright, and
its @selector resolves to data-testid. The 45 dusk attributes left across 18
components selected nothing.
CLAUDE.md was the reason they kept coming back: it told every agent to add
them. Its browser-testing section now describes the setup that exists —
data-testid targeting, the wait helper these tests need because assertions do
not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real.
Verified before removing: every @selector used in tests/Browser resolves to a
data-testid, seven of them through bound :data-testid, so none depended on a
dusk attribute.
* chore: drop the last one-account-per-network helper
hasConnectedPlatform() has no callers left anywhere — app, tests, views or
routes. It sat directly above getSocialAccount(), which this branch already
removed, and is the same leftover from when a workspace could hold one account
per platform.
---------
Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
## Browser Tests (Pest + Playwright)
Browser tests live in `tests/Browser` and run on `pestphp/pest-plugin-browser` driving Playwright. **Laravel Dusk is not installed** — there is no `DuskTestCase` , no `$browser` object, and no `browse()` . Do not add `dusk="..."` attributes; they select nothing.
2026-03-29 21:10:10 +00:00
Allow multiple social accounts per network via env (#286)
* feat: expose self-hosted mode to the accounts UI
SocialAccountObserver already bypasses the one-account-per-network
guard when trypost.self_hosted is true, but the frontend had no way
to know that and always collapsed a network to a single card once
any account existed - so self-hosted deployments could not surface
a second LinkedIn (or Instagram) connection even though the backend
would allow creating it.
* feat: allow connecting multiple accounts per network when self-hosted
NetworkConnectGrid always collapsed a network (LinkedIn profile/page,
Instagram standalone/Facebook) to a single card once any account
existed, with no way to trigger another OAuth flow - even though
SocialAccountObserver already allows unlimited accounts per network
in self-hosted mode. A self-hoster connecting their personal LinkedIn
profile had no path back to the connect flow to also add a company
page (or a second company page/showcase page).
Render one card per connected account instead of collapsing to the
first, and keep a standing "Connect another" card available for a
network's existing connections when self-hosted. Hosted mode is
unchanged: still one card per network, matching the backend's
still-enforced one-account-per-network limit there.
* test: cover the selfHosted prop on accounts and onboarding pages
Backend behavior for connecting a second identity per network in
self-hosted mode was already covered (LinkedInControllerTest,
NetworkUniquenessTest) - these just confirm the new prop the frontend
now depends on is actually present and reflects config correctly.
* style: apply prettier formatting
Pre-existing drift in this file unrelated to the selfHosted change.
* refactor: read selfHosted from shared Inertia props
The flag is already shared by HandleInertiaRequests, so the accounts
and onboarding controllers do not need to pass it again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: gate multiple social accounts with a dedicated env
Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: tighten multiple-account gates after review
Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: bind reconnect to the card and unique social identity
Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: build the OAuth URL before opening the popup
Keep the popup opener URL-only so reconnect query params are assembled at the call site.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: drop dead social-account guards and slim the connect grid
Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: scope social reconnect to the current network
Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: slim connectable-identity filtering
Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: slim social identity persist helpers
Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: keep reconnect updates on the original social card
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: run the suite with multiple social accounts enabled
phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the
true value in .env.ci. That broke eight tests across Automation, MCP,
PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only
needed two accounts of one network as a fixture, not as a rule under test.
Match .env.ci instead. Every test that exercises the one-per-network rule
already sets the config itself; the accounts index test was the only one
leaning on the implicit default, so it now pins it.
* fix: align the multi-account fallback with the self-hosted default
allow_multiple_social_accounts fell back to env('SELF_HOSTED', false)
while self_hosted itself defaults to env('SELF_HOSTED', true). A
self-hosted install that never wrote SELF_HOSTED to its .env resolved to
false and silently lost multiple accounts per network on upgrade, which
is the opposite of what the documented fallback promises.
* fix: collapse duplicate identities before adding the unique index
Installs predating the index can hold the same identity twice: the
network guard was bypassed for multi-account installs and Pinterest
always created a fresh row. Creating the index on that data aborts
migrate mid-deploy.
Keep the newest row per identity and move its post_platforms over before
dropping the duplicates - the FK is nullOnDelete, so deleting outright
would orphan drafts and scheduled posts.
* fix: refuse a reconnect that authorized a different identity
connectIdentity overwrote platform_user_id with whatever the provider
returned, so reconnecting a card while signed into another account
repointed the row - and every draft and scheduled post bound to it - at
a stranger. LinkedIn guarded this at the controller and Facebook via its
filtered page list; nothing covered X, TikTok, Threads, Discord,
Bluesky, Mastodon, Pinterest, Instagram or Telegram.
Enforce the identity match at the single choke point every connect flow
goes through. Every call site already maps NetworkAlreadyConnectedException
to network_taken, so the refusal surfaces without new plumbing.
Also restore the null-platform guard in the observer: occupiesNetwork
type-hints a non-nullable Platform, so a row without one died with a
TypeError instead of the database's NOT NULL error.
* fix: filter connectable identities on every picker step
YouTubeController::select re-fetched the channels and matched the posted
id straight off the raw list, unlike callback and selectChannel. With a
live youtube_oauth session it let a POST name any channel the Google
account owns and bind it to the reconnect target. It also read the
reconnect from the session while the connect below it read
youtube_oauth.reconnect_id, so the two could disagree - pass the
resolved account through instead.
filterConnectableIdentities also short-circuited in multi-account mode,
and the unique index is scoped to platform rather than network. That let
one Instagram account connect twice, once directly and once via
Facebook, publishing every Instagram post to it twice. The existing
except() already spans networkPlatformValues(), so dropping the
short-circuit closes it.
* refactor: type the connect cards and drop the dead accounts grid
The cards computed inferred account as a required ConnectedAccount and
then pushed undefined onto it (TS2345). CI only runs eslint so it stayed
green, but vue-tsc and editors flag it.
SocialAccountsGrid is referenced nowhere; its reconnect button was
updated in this branch without passing the card id, which would have
been a bug had anything rendered it.
* test: keep the suite on the cloud one-account-per-network default
CI runs the Cloud build, so the suite baseline should be the Cloud
default rather than the self-hosted one. Put phpunit.xml and .env.ci
back to false and make the eight tests that merely need two accounts of
one network as a fixture opt in for themselves.
This also un-deads the config()->set(true) calls the branch had already
added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and
SocialAccountObserverTest, which the forced true had turned into no-ops.
* fix: connect standalone instagram instead of reopening the picker
The picker emits an already-resolved connect method, but this branch
rewired @select from openOAuthPopup to startConnect. startConnect sends
a bare 'instagram' straight back into its own picker branch, so choosing
"Instagram" closed the dialog and immediately reopened it - the OAuth
window never opened and the standalone flow was unreachable. Only the
via-Facebook button still worked.
Split the URL-opening tail out of startConnect and let the dialog call
that directly.
* fix: reject a telegram reconnect before burning the connect code
The nonce was consumed before connectIdentity ran, so posting /connect in
the wrong chat spent the one-off code and forced the user to generate a
new one. Check the identity first and report wrong_chat instead of
network_taken, which told them to disconnect an account when the real fix
was posting in the channel they were reconnecting.
* fix: leave one target per post when merging duplicate accounts
post_platforms has no unique on (post_id, social_account_id), so a post
holding a row per duplicate account ended up with two enabled rows aimed
at the surviving account and would publish to it twice. Keep one row per
post, preferring a published one so history survives.
* refactor: collapse the repeated connect-flow boilerplate
Four shapes were copy-pasted across the connect controllers:
- the session + permission guard opening 16 actions, now connectWorkspace()
throwing a ConnectPopupException that renders the popup itself
- the reconnected/connected ternary in 13 places, now connectedCallback()
- the "nothing left to connect" branch in 4 places, now
noConnectableIdentities()
- validatedReconnectId() re-querying what reconnectAccount() already does
Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect
account three or four times per callback; it is resolved once and passed
down. The three GET pickers skipped the manageAccounts check their POST
siblings had, and pick it up from the shared guard.
Drops the color key from connectableOptions and the matching frontend
field - nothing read it. Platform::color() stays; the disconnection
emails use it.
* fix: keep an expired connect popup out of the error log
ConnectPopupException escapes to the framework handler so it can render
itself, which also meant report() ran first: every session_expired and
workspace_not_found popup filed an ERROR and a Nightwatch issue for what
used to be a silent return. A stale popup is a normal outcome, so it now
implements ShouldntReport.
The Mastodon and Threads guards also cleared their provider session
after connectWorkspace(), so a workspace that vanished mid-flow left the
client secret and the OAuth state behind. Clear first, then resolve.
clearMastodonSession() no longer touches social_connect_workspace -
whatever closes the popup already does.
* fix: stop telling users to disconnect an account that is not the problem
Two flows reused popup_callback.network_taken - "This workspace already
has an account for this network. Disconnect it first." - for situations
where that is neither true nor actionable:
- reconnecting a card while signed into a different account on the
provider, now wrong_account
- an empty picker in multi-account mode, where every page or channel on
that login is simply already connected, now all_connected
NetworkAlreadyConnectedException carries the message key so the catch
sites stay one line. handleCallback() also drops its $platform argument;
it read $this->platform for the reconnect lookup and the identity filter
either way, so a caller passing a different platform would have scoped
the lookup to the wrong network.
* refactor: filter linkedin identities with the shared helper
The picker hand-rolled its own reconnect narrowing because the profile
and the pages arrive in two different shapes. Flatten them into one pool
of LinkedIn identities, run the shared filter, and split them again for
the view - the same path Facebook, YouTube and Instagram already take.
Side effect worth having: the picker previously only narrowed on a
reconnect, so it would offer an identity that is already connected and
only fail once the user picked it. It now hides taken identities up
front and says so when nothing is left.
* fix: keep the linkedin picker's own empty state
Routing the picker through the shared filter made every empty pool look
like "nothing left to take", including the pool LinkedIn never filled.
A self-hoster running pages-only who administers no page was told the
network was already connected, or that every account on the login was
taken - both false - and the picker's own "you are not an admin of any
LinkedIn page" state became unreachable.
Only treat it as taken when filtering is what emptied it. Splitting the
pool back also compared the person id loosely on one side and strictly
on the other; one predicate now drives both.
Threads had two forget() calls for a key the top of the action already
clears, and YouTube's picker resolved the reconnect account twice on the
failure path.
* fix: keep the enabled row when collapsing duplicate post targets
SyncPostPlatforms seeds a disabled post_platforms row for every account
in the workspace, so the usual duplicate is one row the user actually
checked next to one they never saw - both pending, both created in the
same second. Ordering only by published-then-newest made that a coin
flip, and PublishPost iterates enabled() only, so half the time a
scheduled post would silently stop reaching that account and take its
caption and per-platform meta with it. This runs once against production
data and the dropped row is gone, so enabled now beats disabled.
Also: the empty-pool exit from the LinkedIn picker was the only one
leaving linkedin_pending - and its tokens - in the session. The
rationale comments move to the docblocks they belong in, and usePage()
comes out of the cards computed.
* fix: stop the migration destroying publish history and automations
Two ways the one-shot merge lost data that cannot be rebuilt:
Surplus published post_platforms rows were deleted. Two duplicate
accounts really could each have published, and each row carries the
platform_post_id for a live post on the network - dropping one leaves
that post unmanageable and invisible to metrics. The docblock claimed
published beat everything; now the code does, and only unpublished
repeats collapse.
Automation nodes persist social_account_id inside a JSON column with no
foreign key, so deleting the loser left RunGenerateNode skipping that
target, or generating nothing at all when it was the node's only
account. The ids are rewritten - current and legacy shapes both - and
entries the merge just turned into duplicates are collapsed.
Ordering is now total (null created_at sorts oldest on every engine,
then id) so a rehearsal on a replica keeps the same rows as the real
run. The LinkedIn picker also passes onboardingProgress inline: it
clears linkedin_pending on the empty path, and a deferred reload would
re-GET the route and swap the empty state for a session-expired popup.
* fix: make the identity merge auditable and stop a second delivery
Self-hosted installs run this unattended and it cannot be undone, so
each collapsed group now logs the workspace, the identity, which row was
kept, which were dropped, and how many post_platforms and automations it
touched. down() says plainly that it drops the index only.
Two narrower fixes:
A post holding a published row plus an enabled unpublished row for the
same account kept both, and PostPlatform::scopeEnabled() filters on
`enabled` alone with no status check - so a republish would deliver the
same content to that identity twice. Once a published row exists, every
unpublished repeat goes.
The automation dedupe ran on every automation in the workspace, not just
the ones the merge rewrote. A node legitimately holding two entries for
one account under different content types would be collapsed to
whichever came first in the array. It now runs only where an id was
actually substituted.
* test: rehearse the identity merge against a messy database
Every test on this migration so far covered a case someone thought to
write, which is why three separate review rounds each found a defect the
earlier ones missed. This builds a deliberately messy database instead -
three workspaces, four networks, one to three copies of each identity,
posts mixing published, pending and failed rows across the duplicates
with enabled flags varying, and automations referencing them in both the
current and legacy JSON shapes - then runs the real migration and
asserts what must be true afterwards rather than what happens to a
particular fixture.
Invariants: no duplicate identity survives, no published row is ever
destroyed, no post ends up enabled twice against one account, nothing in
post_platforms or automations points at a deleted account, and the
newest row of each identity is the one kept.
The generator is seeded, so a failure reproduces, and it asserts its own
output is adversarial - roughly nine duplicate groups and fourteen
published rows - so it cannot quietly degrade into passing on an empty
problem. Verified by mutation: dropping the automation repoint, the
published guard, or the repeated-target collapse each fails exactly the
invariant that covers it.
* fix: stop the youtube picker refetching itself into a cleared session
HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding
- exactly the people connecting their first accounts - so Inertia
re-GETs the picker route right after it mounts. For Facebook and
Instagram that re-entry is harmless and deliberately left deferred, but
YouTube calls the Google API again, and fetchChannels() turns any
failure into an empty list that clears the connect session and swaps the
mounted picker for an error the user cannot retry from. Same guard the
LinkedIn picker already got.
LinkedIn also answered a reconnect that authorized a different identity
with "Page not found", including in the person branch where no page is
involved. Every other platform says wrong_account, which this PR added.
* refactor: drop the unreachable youtube channel picker
Google's own delegation screen already lists every channel on the
account and makes the user pick one before it issues the token, so
channels?mine=true always answers with that single channel and
count($channels) === 1 always won. The picker behind it was never
reached - its Vue page was deleted back in 7c00c338 (January) and
nothing broke, which is the clearest evidence it was dead.
Removes selectChannel(), select(), both routes, the youtube_oauth
session payload and the tests that drove them. If Google ever does
return more than one, the callback connects the first and logs a warning
rather than routing to a screen that no longer exists.
* fix: serialize connects so two popups cannot seat one network twice
The observer's occupiesNetwork() is a check-then-insert with nothing
holding the gap, and the new unique index covers the identity, not the
network. Two tabs finishing OAuth at the same moment for *different*
identities on one network both passed the exists() check and both
inserted, leaving a Cloud workspace with the two accounts the rule
exists to prevent. The same-identity race was already safe - the unique
violation is caught and re-queried.
A database constraint cannot hold this: allow_multiple_social_accounts
is a runtime flag, so the rule is on for Cloud and off for self-hosted,
and an index cannot read config. Lock per workspace and network instead,
the way markAsDisconnected() and ConnectionVerifier already do.
This covers connectIdentity(), which every OAuth flow and the Telegram
action go through. A direct create() still answers to the observer
alone, and a self-hosted install running file cache across several nodes
locks per node.
* fix: handle a busy connect lock on the telegram path
Every other caller funnels LockTimeoutException into its generic
\Exception catch and closes the popup with error_connecting. Telegram
has no such catch, so the new lock could 500 the webhook - and because
the nonce is spent before connectIdentity runs, Telegram's retry of the
same update short-circuits on the consumed code and returns without
dispatching anything. The dialog would spin forever on a code that can
no longer be used.
Also restores coverage the picker removal dropped: the deleted select
tests were the only ones driving a multi-channel response, so nothing
exercised the reconnect narrowing to its own card, or multi-account mode
skipping an already-connected channel. Both are back against the
callback, and removing the narrowing in filterConnectableIdentities
fails them.
* fix: stop the instagram login seating an account already held via facebook
filterConnectableIdentities() drops every identity already connected on the
network, which is what keeps one Instagram account from being seated twice
under its two platforms. Every flow that persists an identity ran it except
the direct Instagram Login callback, so the guard only held in one direction:
InstagramFacebookController refused an account already connected as
`instagram`, but the reverse was allowed through.
With multiple accounts per network enabled the observer's network check is
bypassed and the unique index does not span platforms, so authorizing the
same account through the direct flow created a second row. Both then seed a
post_platform row and the post goes out twice to one account.
* fix: name the real reason when a linkedin profile reconnect switches member
Reconnecting a card narrows the authorized identities to that card's own, so
authorizing a different LinkedIn login empties the pool. selectIdentity()
reported that as "Page not found." for every card, including personal
profiles where no page was ever involved.
A profile reconnect has no page to be missing: an empty pool there can only
mean this login is a different member. Say so with the wrong_account wording
select() already uses for the same condition. Page reconnects keep
page_not_found, where the organization really can be absent from the login.
* fix: surface the busy telegram connect instead of a generic failure
The connect lock timing out dispatches its own 'busy' reason so the dialog
can tell the user to retry, but the dialog only mapped network_taken and
wrong_chat and fell back to error_generic for everything else. The reason
reached the browser and died there, leaving "Could not start the connection"
for a case that just needs another moment.
* test: cover reconnect on every flow that gained it
rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and
Bluesky a reconnect path they did not have before — TikTok had been actively
clearing social_reconnect_id on connect — and none of them had a test for it.
Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did.
Each now covers both halves: authorizing the same identity refreshes the
existing card and reports it as a reconnect, and authorizing a different one
is refused with wrong_account instead of quietly seating a stranger on the
card and every post scheduled against it.
* fix: repair what a reconnect leaves behind when it cannot proceed cleanly
Two things connectIdentity got wrong once the reconnect path existed.
A reconnect through the other variant of a network moves the card to the new
platform — same identity, different API flavor. Post targets carry their own
platform snapshot, and that snapshot picks the publisher, the queue and the
scopes checked before publishing. Left behind, it failed every pending post on
a permission the account no longer needs: an Instagram card moved to the
Facebook variant still demanded instagram_business_content_publish and stopped
with "Missing permissions". Pending targets now follow the card and reset a
content type the new platform cannot publish; published targets keep theirs,
since they record what really went out under a platform_post_id from that API.
The network lock timing out also arrived as a raw LockTimeoutException, which
every OAuth callback filed through its generic catch: an error log and "Error
connecting account" for the exact race the lock exists to absorb. It now
carries a busy messageKey through the branch each flow already handles, the
same way the Telegram path already reported it.
* refactor: resolve the linkedin reconnect card once per select
select() already looked the card up before deciding whether the chosen
identity matches it, then connectPerson() and connectOrganization() looked it
up again on their own — two identical queries per submit, and two places that
could disagree about what is being reconnected. The caller passes what it
already holds.
* test: render the grid's multi-account branch
phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test
overrode it, so the card the flag exists to add never rendered anywhere. The
pair pins both sides: a taken network offers no second card when multiples are
off, and offers one when they are on.
* test: pin why the linkedin select guards exist
connectIdentity() already refuses a mismatched reconnect and answers with the
same wrong_account message, so every existing test passes with the two guards
in select() deleted — which is exactly how they would get deleted. What they
actually buy is skipping the avatar download that building the connect payload
runs first.
Both now assert the fetch never happens, so the guards fail loudly instead of
looking redundant.
* fix: carry retrying targets through a variant move, atomically
Two holes in the move added a commit ago.
It only carried pending targets, but a retrying one is not finished either —
the publish job reschedules itself and reads the snapshot fresh on the next
attempt, so leaving it behind meant it retried against the old variant until
it exhausted its budget on a permission the account no longer needs. Failed
and published targets stay put; a publishing one has a job mid-flight already
working from the snapshot it read.
The card and its targets also moved in three separate statements, so a crash
between them left exactly the split this was meant to close. They share a
transaction now.
* chore: drop the dusk selectors nothing reads
Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase,
no browse(). Browser tests run on pest-plugin-browser driving Playwright, and
its @selector resolves to data-testid. The 45 dusk attributes left across 18
components selected nothing.
CLAUDE.md was the reason they kept coming back: it told every agent to add
them. Its browser-testing section now describes the setup that exists —
data-testid targeting, the wait helper these tests need because assertions do
not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real.
Verified before removing: every @selector used in tests/Browser resolves to a
data-testid, seven of them through bound :data-testid, so none depended on a
dusk attribute.
* chore: drop the last one-account-per-network helper
hasConnectedPlatform() has no callers left anywhere — app, tests, views or
routes. It sat directly above getSocialAccount(), which this branch already
removed, and is the same leftover from when a workspace could hold one account
per platform.
---------
Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
- ALWAYS use named routes via `route()` . NEVER hardcode URLs like `'https://trypost.test/login'` .
- Example: `visit(route('login'))` .
- ALWAYS target elements by `data-testid` . NEVER use CSS classes (`.text-red-600`), tag names, or text strings.
- `@my-element` resolves to `[data-testid="my-element"]` , so add `data-testid="my-element"` in the Vue component and use `$page->click('@my-element')` .
- Bind it for repeated elements: `:data-testid="` connect-${platform.value}`"`.
- Assertions do NOT auto-wait on SPA paint. Wait for the element to mount and lay out first — see the `waitFor*TestId()` helper at the top of `tests/Browser/WelcomeConnectTest.php` and copy the pattern under a file-unique name (these helpers are global functions; a duplicated name collides across test files).
- `BrowserTestCase` sets `$fakesVite = false` on purpose: these tests load real built assets, so faking Vite blanks the app.
- End page assertions with `->assertNoJavaScriptErrors()` .
- CI runs them un-parallelised (`php artisan test tests/Browser --compact`) against `npm run build` output, so keep them independent of a running dev server.
2026-03-29 21:10:10 +00:00
## Array Data Access
- In Action classes and similar service classes, ALWAYS use Laravel's `data_get()` helper instead of direct array access.
- Example: `data_get($data, 'name')` instead of `$data['name']` .
- Use the third parameter for fallback values: `data_get($data, 'username', $sender->username)` instead of `$data['username'] ?? $sender->username` .
2026-06-14 19:12:49 +00:00
## Eloquent Models & Morph Map
- EVERY Eloquent model in `app/Models` MUST be registered in `Relation::enforceMorphMap([...])` inside `AppServiceProvider::configureMorphMap()` , keyed by a camelCase alias (e.g. `'postPlatform' => PostPlatform::class` ).
- When you add a new model, add it to the morph map in the same change. `tests/Unit/MorphMapTest.php` fails if any model is missing.
- The alias is persisted in polymorphic columns, so never rename or remove an existing alias for a model that has stored rows.
2026-04-14 21:44:47 +00:00
## Imports
- NEVER use inline class references (e.g., `\DB::listen` , `\Str::uuid()` ). ALWAYS import classes at the top of the file with a `use` statement.
- PHP: `use Illuminate\Support\Facades\DB;` then `DB::listen(...)`
- TypeScript/Vue: `import { ref } from 'vue'` then `ref(...)`
2026-03-29 21:10:10 +00:00
## API Response Status Codes
- When returning JSON responses with explicit status codes, always use `Symfony\Component\HttpFoundation\Response` constants instead of magic numbers.
- Example: `Response::HTTP_CREATED` instead of `201` , `Response::HTTP_NO_CONTENT` instead of `204` .
2026-05-08 22:22:01 +00:00
## String Interpolation
- When injecting variables into strings, prefer **double-quoted interpolation** with curly braces over concatenation with `.` .
- PHP: `"workspace.{$workspace->id}"` instead of `'workspace.'.$workspace->id` .
- Use curly braces `{}` even for simple variables to keep the boundary explicit and to allow object/array access without ambiguity.
- Single quotes are still preferred when the string has no interpolation.
2026-05-19 12:37:16 +00:00
## External Service URLs
- NEVER hardcode third-party API hosts, OAuth endpoints, or per-platform service URLs (e.g. `https://api.x.com/2` , `https://www.linkedin.com/oauth/v2/accessToken` , `https://bsky.social` ). They live in `config/trypost.php` under `platforms.<name>` with a matching `env(...)` default, so self-hosted users can override them and we have a single source of truth.
- Production code: `config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken'` , never the literal URL.
- Tests: use the same `config(...)` value in `Http::fake([...])` — `Http::fake([config('trypost.platforms.x.api').'/oauth2/token' => ...])` . Tests with hardcoded URLs drift silently when the config changes.
- Path/route segments after the host (e.g. `/oauth/v2/accessToken` , `/xrpc/com.atproto.server.refreshSession` ) are part of the provider's protocol spec — those stay inline next to the call. Only the host comes from config.
2026-08-09 23:14:36 +00:00
## Social Platform API Documentation (official sources)
**Always consult the official docs below before implementing or changing OAuth, publishing, deletion, rate-limit, or any other platform-specific behavior — never guess endpoints, scopes, rate limits, or capabilities from memory.** APIs shift over time; a behavior confirmed in a past session may no longer hold. One entry per social network we integrate with:
- **Facebook / Instagram / Threads (Meta)**: all three share the Graph API error format (`error.code`, `error.type` ).
- General error handling / codes 1, 2, 4, 17, 190: https://developers.facebook.com/docs/graph-api/guides/error-handling/
- Rate limiting — Platform Rate Limits (app/user tokens, codes 4/17) vs. Business Use Case (BUC) Rate Limits (Page/system-user tokens, codes 80000– 80014 — e.g. `80001` Pages API, `80002` Instagram Platform; BUC rejections come back as plain HTTP 400, not 429): https://developers.facebook.com/docs/graph-api/overview/rate-limiting/
- Instagram content-publishing error codes: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/
- Instagram media reference (incl. `DELETE` ): https://developers.facebook.com/docs/instagram-platform/reference/instagram-media/
- Threads API: https://developers.facebook.com/docs/threads — reuses the Graph API error format; no separate Threads-specific error code table exists. Delete posts (needs the separate `threads_delete` permission, 100 deletes/day/account): https://developers.facebook.com/docs/threads/posts/delete-posts/
- Our `App\Services\Social\Meta\GraphError` (used by `ConnectionVerifier` 's verify/refresh calls) has the full rationale and code table in its class docblock — check there before changing transient-vs-confirmed-rejection classification.
- `Facebook` /`InstagramFacebook` `SocialAccount` s use a Facebook Page access token (BUC-limited); `Instagram` (direct login) and `Threads` use a user access token (Platform Rate Limit-limited). This affects which rate-limit codes apply to which platform.
- **X (Twitter)**: API v2 — https://docs.x.com/x-api ; Post management (create/delete) — https://docs.x.com/x-api/posts/manage-tweets/introduction
- **LinkedIn**: Posts API (create/update/delete, member + organization) — https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api (replaces the deprecated `ugcPosts` API)
- **Mastodon**: Statuses API — https://docs.joinmastodon.org/methods/statuses/
- **Pinterest**: API v5 reference — https://developers.pinterest.com/docs/api/v5/
- **YouTube**: Data API v3 — https://developers.google.com/youtube/v3/docs
- **TikTok**: Content Posting API — https://developers.tiktok.com/doc/content-posting-api-reference-direct-post — **no delete/unpublish endpoint exists** ; a published post can only be removed manually inside the TikTok app
- **Bluesky / AT Protocol**: official lexicons — https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo ; HTTP API reference — https://docs.bsky.app
- **Discord**: Webhook resource (used for our webhook-based publishing) — https://docs.discord.com/developers/resources/webhook
- **Telegram**: Bot API — https://core.telegram.org/bots/api
fix: detect dead Threads/Instagram/Facebook tokens reported under non-190 codes (#254)
* fix: detect dead Threads/Instagram/Facebook tokens reported under non-190 codes
verifyThreads/verifyInstagram/verifyFacebook only threw TokenExpiredException
for Meta error code 190, silently returning false for every other rejection
(e.g. code 100 "The requested resource does not exist"). The hourly
VerifyWorkspaceConnections check never saw that false, so a genuinely dead
token went unflagged — no reconnect email — until the real scheduled post
tried to publish and failed with the same raw error (#230).
GraphError::isTransient() now isolates the known rate-limit/transient codes
(1, 2, 4, 17); everything else on a failed verify/refresh is a confirmed
rejection and raises TokenExpiredException, while transient/5xx/429 raises
PlatformUnavailableException so the account isn't disconnected on a throttle.
Also drops the unused $errorType variable from the three *PublishException
classes.
* fix: treat unparseable Meta failure bodies as transient, drop dead code
Code review on #254 found two issues in the original fix:
- The inverted classifier (`! GraphError::isTransient($body)`) treated a
response body that fails to parse as JSON (WAF block page, truncated
response, gateway hiccup) as a confirmed dead token, since isTransient()
returns false for a body it can't recognize. That flipped a null/unparseable
body from "retry later" (PlatformUnavailableException, the pre-fix behavior)
to "disconnect now" (TokenExpiredException) for both the Threads/Instagram
refresh classifiers and the verify path's classifyMetaVerifyFailure. Fixed
by treating a null body as transient at both call sites — we have no
confirmed rejection from Meta to act on.
- GraphError::indicatesInvalidToken() had no remaining production callers
after the refresh classifiers switched to isTransient() — removed it and
its tests instead of leaving dead code behind.
* test: symmetric Facebook/Instagram coverage for the shared verify classifier
verifyInstagram/verifyFacebook/verifyThreads all delegate to the same
classifyMetaVerifyFailure(), so the non-190 dead-token, rate-limit,
5xx, and non-JSON-body cases were only exercised end-to-end for
Threads. Adds the missing Facebook (rate-limit, 5xx, non-JSON) and
Instagram (non-190 dead token, 5xx) cases so each platform has direct
proof, not just shared-code inference.
* fix: recognize Business Use Case (BUC) rate-limit codes for Page-token accounts
Verified the transient-code list against Meta's official docs. Confirmed:
codes 1, 2, 4, 17, 190 match what's documented at
developers.facebook.com/docs/graph-api/guides/error-handling/. But Meta runs
a SECOND, separately-coded rate-limit system (Business Use Case / BUC) for
Page and system-user tokens — which is exactly what our Facebook and
InstagramFacebook accounts use. BUC rejections come back as a plain HTTP 400
(not 429) with codes in the 80000 range (80001 Pages API, 80005 Instagram
Platform), which GraphError::isTransient() didn't recognize — meaning a
throttled Facebook/InstagramFacebook Page token would have been misclassified
as a confirmed dead token and disconnected.
- Added 80001/80005 to GraphError::TRANSIENT_CODES, with sources.
- Added GraphError::isTransientFailure(Response) to fold the status-based
checks (5xx, 429) and body-based checks together into one documented
method, replacing the ad-hoc multi-condition `if` that lived inline in
ConnectionVerifier::classifyMetaVerifyFailure().
- isTransient() now treats a null (unparseable) body as transient directly,
so the refresh-path classifiers no longer need a separate null guard.
- Documented the full code table, sources, and per-platform token-type
notes (Page token vs. user token, which rate-limit system applies to
which platform) in GraphError's class docblock and in CLAUDE.md, so
future changes here start from verified sources instead of guessing.
* refactor: move Meta verify-failure classification into GraphError
classifyMetaVerifyFailure() lived in ConnectionVerifier but never touched
$this, SocialAccount, or the cache lock — it was a pure (Response, label) ->
Exception translation, same shape as what TokenRefreshClient already owns
for the refresh side. Keeping it in ConnectionVerifier broke that symmetry
and split Meta error interpretation across two classes instead of the one
(GraphError) whose docblock already says that's its job.
Moved as GraphError::classifyVerifyFailure(), dropped the now-unused
Response import from ConnectionVerifier, and added direct unit tests for
the new public method alongside the existing ConnectionVerifierTest
coverage that exercises it through verify().
* fix: correct Instagram Platform BUC code from 80005 to 80002
My earlier WebFetch of Meta's rate-limiting page mis-parsed the BUC code
table and mapped 80005 to Instagram Platform. It's actually Lead Generation
(Marketing API, which this app never calls) — Instagram Platform is 80002.
Verified against a raw, unsummarized reproduction of the same official page
(developers.facebook.com/docs/graph-api/overview/rate-limiting/) plus
independent third-party corroboration, both pointing to 80002.
Also closes a test-coverage gap flagged in review: GraphError::isTransient()
now intentionally treats a parseable body with no "error" key (e.g.
{"data": {...}}) as a confirmed rejection, not transient — a real behavior
change from the pre-#254 code, which silently ignored that shape. Added
explicit unit + integration coverage for it so the decision is asserted,
not implicit.
2026-08-08 16:24:10 +00:00
2026-05-02 15:22:42 +00:00
## TryPost.it Documentation
2026-03-29 21:10:10 +00:00
2026-05-02 15:22:42 +00:00
- All our documentation to final user it's under https://docs.trypost.it
2026-04-01 15:08:36 +00:00
Defuse links in X posts to avoid the link-post fee (#308)
X bills a post containing a URL at a much higher rate than a plain post, and
its algorithm demotes link posts. The X version of a post now rewrites every
URL non-clickable (https://example.com/post becomes example(.)com/post):
scheme and www. dropped, every dot of the host replaced with (.).
Leaving a single dot intact would still leave a resolvable domain for X to
detect, so all of them are broken. A scheme or www. proves a token is a URL on
its own; a bare host only counts when its last label is a delegated TLD, which
is the one thing telling acme.com apart from Node.js. That check runs against
App\Support\LinkTlds, generated from the whole IANA root zone in every form a
TLD can appear in a post -- ASCII, punycode and the Unicode it decodes to --
because whatever X links is what X bills, so a hand-picked subset would leave
us paying for its gaps. If the regex engine bails out on pathological input the
original content is returned instead of crashing the publisher.
The transform lives in the Platform::X arm of ContentSanitizer, so it reaches
publishing and the app/API/MCP previews from one place and cannot touch any
other network. Off by default; opt in with X_DEFUSE_LINKS.
The editor counts characters and renders its preview client-side and cannot ask
the server on every keystroke, so the rewrite is mirrored in TypeScript. PHP
stays the source of truth: a parity test fails if the two TLD sets drift, and a
browser test drives the real editor so the mirror is covered rather than
assumed. Without it the composer promised text the network never receives.
Character limits now measure the text a reader will see: sanitized, then with
markup resolved away. Measuring the raw draft blocked saving posts that publish
fine and let through posts the network rejects, and counted the editor's HTML
toward the limit. Measuring the sanitized form alone would have counted
Telegram's escaped entities, rejecting messages Telegram accepts.
Empty content is handled once inside the sanitizer instead of by a guard
repeated at every call site.
2026-08-29 18:31:05 +00:00
## X link defusing (env knob)
X bills a post containing a URL at ** $0.20** vs ** $0.015** for a plain post (13x), and its algorithm demotes link posts. So on Cloud the `ContentSanitizer` rewrites every URL in the X version of a post into a non-clickable form — `https://example.com/post` becomes `example(.)com/post` .
| Env | Config | Default | Effect |
| --- | --- | --- | --- |
| `X_DEFUSE_LINKS` | `trypost.platforms.x.defuse_links` | `false` | `true` : URLs in the X version of a post are rewritten non-clickable (scheme and `www.` dropped, **every** dot of the host replaced with `(.)` ). `false` : the X content is published unchanged. Only affects `Platform::X` — every other network keeps the URL intact. |
Standing constraints:
- The transform lives in ONE place: the `Platform::X` arm of `App\Services\Social\ContentSanitizer::sanitize()` . Never re-implement it in a publisher or add a `$defuseLinks` parameter to `sanitize()` — a per-call-site flag gets forgotten at the next entry point and we silently start paying again. Because `PostPreviewer` also goes through `ContentSanitizer` , the app/API/MCP previews show the defused text for free.
- **Every** dot of the host must be broken. Defusing only the dot before the TLD leaves `blog.example.com` in `blog.example.com(.)br` , which X still detects and bills.
- A URL carrying `https://` , `http://` or `www.` is defused on sight. A **bare** host is only a link when its last label is a delegated TLD — that check is the one thing separating `acme.com` from `Node.js` , and it goes through `App\Support\LinkTlds` , which mirrors the full IANA root zone rather than a hand-picked subset. Never replace it with "any 2+ letters after a dot", and never trim it back to a curated list: whatever X links is what X bills, so the two must stay in step. `README.md` and `backup.zip` are defused on purpose — `.md` and `.zip` are real TLDs and X links them too.
- Off by default everywhere. Cloud opts in; self-hosted installs publish through their own X app and pay their own bill, so they only turn it on if they want to.
- Character limits are measured against the **sanitized** content — the string the publisher actually sends — in both `App\Rules\ContentFitsPlatformLimits` (save/schedule) and `HasSocialHttpClient::validateContentLength()` (publish). The editor stores HTML and per-platform rules change the length again, so measuring the raw draft blocks saving posts that publish fine and lets through posts the network rejects. Keep the two in step.
- Tests enable it explicitly with `config()->set('trypost.platforms.x.defuse_links', true)` rather than pinning an env, so the suite runs against the shipped default.
- The editor counts characters and renders the X preview client-side, so the rewrite is mirrored in `resources/js/lib/defuseXLinks.ts` . The TLD list is NOT duplicated there: `PostController@edit` sends `App\Support\LinkTlds::all()` as the `xLinkTlds` page prop, and only when defusing is on — an empty set means the feature is off, since without the list a bare host cannot be told from `Node.js` . Do not move it to the Inertia shared props; only the editor needs it. Two tests keep the mirror honest: `XLinkDefusingParityTest` runs a shared corpus through both engines over the same list and diffs the output, and `tests/Browser/XLinkDefusingTest.php` drives the real editor.
- Neither expression may use lookbehind. Safari only understands it from 16.4, esbuild cannot transpile it, and a `SyntaxError` there takes down the whole chunk — the character before a candidate URL is consumed and put back instead.
2026-03-29 21:10:10 +00:00
## Git
- NEVER add `Co-Authored-By` lines to commit messages.
- NEVER commit, push, or open PRs unless explicitly asked by the user.
- Always create a new branch for feature work before making changes.