diff --git a/.claude/skills/pennant-development/SKILL.md b/.claude/skills/pennant-development/SKILL.md index 3b364661..85283f5f 100644 --- a/.claude/skills/pennant-development/SKILL.md +++ b/.claude/skills/pennant-development/SKILL.md @@ -8,14 +8,6 @@ # Pennant Features -## When to Apply - -Activate this skill when: - -- Creating or checking feature flags -- Managing feature rollouts -- Implementing A/B testing - ## Documentation Use `search-docs` for detailed Pennant patterns and documentation. diff --git a/CLAUDE.md b/CLAUDE.md index 12cd394f..84b0ca4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,16 +12,17 @@ ## Foundational Context - php - 8.4 - inertiajs/inertia-laravel (INERTIA_LARAVEL) - v3 - laravel/ai (AI) - v0 -- laravel/boost (BOOST) - v2 - laravel/cashier (CASHIER) - v16 - laravel/framework (LARAVEL) - v13 - laravel/horizon (HORIZON) - v5 - laravel/mcp (MCP) - v0 - laravel/nightwatch (NIGHTWATCH) - v1 +- laravel/pennant (PENNANT) - v1 - laravel/prompts (PROMPTS) - v0 - laravel/reverb (REVERB) - v1 - laravel/socialite (SOCIALITE) - v5 - laravel/wayfinder (WAYFINDER) - v0 +- laravel/boost (BOOST) - v2 - laravel/pail (PAIL) - v1 - laravel/pint (PINT) - v1 - laravel/sail (SAIL) - v1 @@ -44,13 +45,15 @@ ## Skills Activation - `cashier-stripe-development` — Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues. - `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns. - `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching. -- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP. +- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-\* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP. +- `pennant-development` — Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems. - `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication. - `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task - `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code. - `inertia-vue-development` — Develops Inertia.js v3 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using ,
, useForm, useHttp, setLayoutProps, or router; working with deferred props, prefetching, optimistic updates, instant visits, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation. - `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS. -- `ai-sdk-development` — TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for Prism PHP or other AI packages used directly. +- `ai-sdk-development` — Builds AI agents, generates text and chat responses, produces images, synthesizes audio, transcribes speech, generates vector embeddings, reranks documents, and manages files and vector stores using the Laravel AI SDK (laravel/ai). Supports structured output, streaming, tools, conversation memory, middleware, queueing, broadcasting, and provider failover. Use when building, editing, updating, debugging, or testing any AI functionality, including agents, LLMs, chatbots, text generation, image generation, audio, transcription, embeddings, RAG, similarity search, vector stores, prompting, structured output, or any AI provider (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). +- `medialibrary-development` — Build and work with spatie/laravel-medialibrary features including associating files with Eloquent models, defining media collections and conversions, generating responsive images, and retrieving media URLs and paths. ## Conventions @@ -116,12 +119,13 @@ ## Tinker - 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. - 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();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` === php rules === # PHP +- Always declare `declare(strict_types=1);` at the top of every `.php` file. - 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` @@ -215,6 +219,7 @@ ## Pest - This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. - Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. +- When running locally, always pass `--parallel` to speed up the suite (e.g. `php artisan test --parallel --compact`). Combine with `--filter` or specific paths when iterating on a small set of tests. - Do NOT delete tests without approval. === inertia-vue/core rules === @@ -222,8 +227,23 @@ ## Pest # Inertia + Vue Vue components must have a single root element. + - IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns. +=== laravel/ai rules === + +## Laravel AI SDK + +- This application uses the Laravel AI SDK (`laravel/ai`) for all AI functionality. +- Activate the `developing-with-ai-sdk` skill when building, editing, updating, debugging, or testing AI agents, text generation, chat, streaming, structured output, tools, image generation, audio, transcription, embeddings, reranking, vector stores, files, conversation memory, or any AI provider integration (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). + +=== spatie/laravel-medialibrary rules === + +## Media Library + +- `spatie/laravel-medialibrary` associates files with Eloquent models, with support for collections, conversions, and responsive images. +- Always activate the `medialibrary-development` skill when working with media uploads, conversions, collections, responsive images, or any code that uses the `HasMedia` interface or `InteractsWithMedia` trait. + # Project-Specific Rules @@ -238,8 +258,6 @@ ## Icons (@tabler/icons-vue) - 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 -- Button never use icon + text, it's icon or text, never together -- dropdown lists always use icon + text, and both have the same color ## Dates @@ -277,6 +295,12 @@ ## Array Data 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`. +## 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(...)` + ## API Response Status Codes - When returning JSON responses with explicit status codes, always use `Symfony\Component\HttpFoundation\Response` constants instead of magic numbers. @@ -284,83 +308,7 @@ ## API Response Status Codes ## Sendkit Documentation -- All our documentation to final user it's under https://sendkit.dev/docs - -## Social Platform API References - -Always verify against official docs before making changes to publishers, media handling, or content formatting. - -### Content Format - -| Platform | Content field | Format | Official docs | -|---|---|---|---| -| Instagram | `caption` | Plain text | [IG Media](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | -| Facebook | `message` | Plain text | [Page Feed](https://developers.facebook.com/docs/graph-api/reference/page/feed/) | -| X/Twitter | `text` | Plain text | [Post creation](https://docs.x.com/x-api/posts/creation-of-a-post) | -| TikTok | `title` | Plain text | [Content Posting](https://developers.tiktok.com/doc/content-posting-api-reference-direct-post) | -| YouTube | `title`, `description` | Plain text | [Videos.insert](https://developers.google.com/youtube/v3/docs/videos/insert) | -| Threads | `text` | Plain text | Same as Instagram | -| Pinterest | `description` | Plain text | [Pin creation](https://developers.pinterest.com/docs/api/v5/pins-create/) | -| LinkedIn | `commentary` | Plain text (Unicode bold/underline works visually) | [Posts API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api) | -| Bluesky | `text` + facets | Plain text + facets for links/mentions | [AT Protocol posts](https://docs.bsky.app/docs/advanced-guides/posts) | -| Mastodon | `status` | HTML subset (p, strong, em, a, br, span) | [Statuses API](https://docs.joinmastodon.org/methods/statuses/) | - -### Image Specs - -| Platform | Max size | Formats | Max resolution | Official docs | -|---|---|---|---|---| -| Instagram | 8 MB | JPEG only | 1440px width | [IG Media](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | -| Facebook | 4 MB | JPEG, PNG, BMP, GIF, TIFF | Auto-resized | [Page Photos](https://developers.facebook.com/docs/graph-api/reference/page/photos/) | -| X/Twitter | 5 MB | JPG, PNG, GIF, WEBP | No hard limit | [Media best practices](https://docs.x.com/x-api/media/quickstart/best-practices) | -| TikTok | 20 MB | JPEG, WebP | 1080px max | [Media transfer](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide) | -| LinkedIn | < 36M pixels | JPG, GIF, PNG | < 36,152,320 pixels | [Images API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api) | -| Pinterest | 20 MB | PNG, JPEG | 1000x1500 recommended | [Product specs](https://help.pinterest.com/en/business/article/pinterest-product-specs) | -| Bluesky | 1 MB | Any | No hard limit | [AT Protocol](https://docs.bsky.app/docs/advanced-guides/posts) | -| Mastodon | ~10 MB | JPG, PNG, GIF, WebP | No hard limit | [Statuses API](https://docs.joinmastodon.org/methods/statuses/) | - -### Video Specs - -| Platform | Max size | Formats | Duration | Official docs | -|---|---|---|---|---| -| Instagram Reel | 300 MB | MP4, MOV | 3s-15min | [IG Media](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | -| Instagram Story | 100 MB | MP4, MOV | 3-60s | [IG Media](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media) | -| Facebook | 2 GB | MP4 | 1s-40min | [Video API errors](https://developers.facebook.com/docs/video-api/reference/error-codes/) | -| X/Twitter | 512 MB | MP4 | 0.5-140s | [Media best practices](https://docs.x.com/x-api/media/quickstart/best-practices) | -| TikTok | 4 GB | MP4, WebM, MOV | Up to 10min | [Media transfer](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide) | -| YouTube Shorts | 128 GB | MP4, MOV, AVI, WebM | Up to 60s | [Videos.insert](https://developers.google.com/youtube/v3/docs/videos/insert) | -| LinkedIn | 500 MB | MP4 | 3s-30min | [Videos API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api) | -| Pinterest | 2 GB | MP4, MOV, M4V | 4s-15min | [Product specs](https://help.pinterest.com/en/business/article/pinterest-product-specs) | -| Bluesky | 50 MB | MP4 | Up to 60s | [AT Protocol](https://docs.bsky.app/docs/advanced-guides/posts) | -| Mastodon | ~40 MB | MP4, WebM | No limit | [Statuses API](https://docs.joinmastodon.org/methods/statuses/) | - -### Error Codes - -| Platform | Error docs | -|---|---| -| Instagram | [Error codes](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/) | -| Facebook | [Graph API errors](https://developers.facebook.com/docs/graph-api/guides/error-handling/) + [Video API errors](https://developers.facebook.com/docs/video-api/reference/error-codes/) | -| X/Twitter | [Response codes](https://docs.x.com/x-api/fundamentals/response-codes-and-errors) | -| TikTok | [Error handling](https://developers.tiktok.com/doc/tiktok-api-v2-error-handling) + [Publish status](https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status) | -| YouTube | [Videos.insert errors](https://developers.google.com/youtube/v3/docs/videos/insert) | -| LinkedIn | [Posts API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api) + [Videos API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api) | -| Pinterest | [API v5](https://developers.pinterest.com/docs/api/v5/) | -| Bluesky | [AT Protocol](https://docs.bsky.app/docs/advanced-guides/posts) | -| Mastodon | [Statuses API](https://docs.joinmastodon.org/methods/statuses/) | - -### Upload Method Per Platform - -| Platform | Method | Notes | -|---|---|---| -| Instagram | URL pull (`image_url`/`video_url`) | Platform downloads from our CDN | -| Facebook | URL pull (`url`/`file_url`) for posts; binary upload for reels/stories | | -| X/Twitter | Binary upload (chunked for video/GIF) | | -| TikTok | URL pull (`PULL_FROM_URL`) | | -| YouTube | Google SDK chunked upload | Uses `google/apiclient` PHP SDK | -| Threads | URL pull (`image_url`/`video_url`) | | -| LinkedIn | Binary upload (image PUT, video chunked) | | -| Pinterest | Base64 for images, multipart for video | | -| Bluesky | Binary blob upload | 1MB hard limit per blob | -| Mastodon | Binary upload via multipart | | +- All our documentation to final user it's under https://docs.sendkit.dev ## Git diff --git a/app/Actions/Workspace/DeleteWorkspace.php b/app/Actions/Workspace/DeleteWorkspace.php index b9bf224f..a6d1ef83 100644 --- a/app/Actions/Workspace/DeleteWorkspace.php +++ b/app/Actions/Workspace/DeleteWorkspace.php @@ -13,6 +13,10 @@ public static function execute(User $user, Workspace $workspace): void { User::where('current_workspace_id', $workspace->id)->update(['current_workspace_id' => null]); + if (! config('trypost.self_hosted') && $workspace->subscribed(Workspace::SUBSCRIPTION_NAME)) { + $workspace->subscription(Workspace::SUBSCRIPTION_NAME)->cancel(); + } + $workspace->delete(); } } diff --git a/app/Features/AiImagesLimit.php b/app/Features/AiImagesLimit.php new file mode 100644 index 00000000..2a147b6f --- /dev/null +++ b/app/Features/AiImagesLimit.php @@ -0,0 +1,15 @@ +plan?->ai_images_limit ?? 50; + } +} diff --git a/app/Features/AiVideosLimit.php b/app/Features/AiVideosLimit.php new file mode 100644 index 00000000..5be8ce94 --- /dev/null +++ b/app/Features/AiVideosLimit.php @@ -0,0 +1,15 @@ +plan?->ai_videos_limit ?? 10; + } +} diff --git a/app/Features/BrandLimit.php b/app/Features/BrandLimit.php new file mode 100644 index 00000000..1ec225c3 --- /dev/null +++ b/app/Features/BrandLimit.php @@ -0,0 +1,15 @@ +plan?->brand_limit ?? 0; + } +} diff --git a/app/Features/DataRetentionDays.php b/app/Features/DataRetentionDays.php new file mode 100644 index 00000000..f2ac88c5 --- /dev/null +++ b/app/Features/DataRetentionDays.php @@ -0,0 +1,15 @@ +plan?->data_retention_days ?? 30; + } +} diff --git a/app/Features/MemberLimit.php b/app/Features/MemberLimit.php new file mode 100644 index 00000000..49eaf860 --- /dev/null +++ b/app/Features/MemberLimit.php @@ -0,0 +1,15 @@ +plan?->member_limit ?? 1; + } +} diff --git a/app/Features/SocialAccountLimit.php b/app/Features/SocialAccountLimit.php new file mode 100644 index 00000000..e6c9a021 --- /dev/null +++ b/app/Features/SocialAccountLimit.php @@ -0,0 +1,15 @@ +plan?->social_account_limit ?? 5; + } +} diff --git a/app/Http/Controllers/App/BrandController.php b/app/Http/Controllers/App/BrandController.php index 83fc3b46..c375de6d 100644 --- a/app/Http/Controllers/App/BrandController.php +++ b/app/Http/Controllers/App/BrandController.php @@ -65,10 +65,6 @@ public function update(UpdateBrandRequest $request, Brand $brand): RedirectRespo $this->authorize('update', $brand); - if ($brand->workspace_id !== $workspace->id) { - abort(403); - } - $brand->update([ 'name' => data_get($request->validated(), 'name'), ]); @@ -79,9 +75,9 @@ public function update(UpdateBrandRequest $request, Brand $brand): RedirectRespo return redirect()->route('app.brands.index'); } - public function destroy(Brand $brand): RedirectResponse + public function destroy(Request $request, Brand $brand): RedirectResponse { - $workspace = request()->user()->currentWorkspace; + $workspace = $request->user()->currentWorkspace; if (! $workspace) { return redirect()->route('app.workspaces.create'); @@ -89,10 +85,6 @@ public function destroy(Brand $brand): RedirectResponse $this->authorize('delete', $brand); - if ($brand->workspace_id !== $workspace->id) { - abort(403); - } - $brand->delete(); session()->flash('flash.banner', __('Brand deleted successfully.')); diff --git a/app/Http/Controllers/App/OnboardingController.php b/app/Http/Controllers/App/OnboardingController.php index c4563d0a..da60b991 100644 --- a/app/Http/Controllers/App/OnboardingController.php +++ b/app/Http/Controllers/App/OnboardingController.php @@ -92,7 +92,7 @@ public function storeConnect(Request $request): SymfonyResponse|RedirectResponse $user->update(['setup' => Setup::Subscription]); - $defaultPlan = Plan::where('slug', PlanSlug::Starter)->first(); + $defaultPlan = Plan::where('slug', PlanSlug::Starter)->firstOrFail(); $workspace->createOrGetStripeCustomer([ 'email' => $workspace->stripeEmail(), diff --git a/app/Http/Controllers/Auth/BlueskyController.php b/app/Http/Controllers/Auth/BlueskyController.php index 13b1b8d2..992a7b81 100644 --- a/app/Http/Controllers/Auth/BlueskyController.php +++ b/app/Http/Controllers/Auth/BlueskyController.php @@ -29,6 +29,7 @@ public function connect(Request $request): Response|RedirectResponse } $this->authorize('manageAccounts', $workspace); + $this->ensureSocialAccountLimit($workspace); return Inertia::render('accounts/BlueskyConnect', [ 'errors' => session('errors')?->getBag('default')?->toArray() ?? [], diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index 2ec4e96d..e6ea43d3 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -42,6 +42,7 @@ public function connect(Request $request): Response|RedirectResponse } $this->authorize('manageAccounts', $workspace); + $this->ensureSocialAccountLimit($workspace); $existingAccount = $workspace->socialAccounts() ->where('platform', $this->platform->value) diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php index d88b71a3..b2e476af 100644 --- a/app/Http/Controllers/Auth/InstagramController.php +++ b/app/Http/Controllers/Auth/InstagramController.php @@ -38,6 +38,7 @@ public function connect(Request $request): Response|RedirectResponse } $this->authorize('manageAccounts', $workspace); + $this->ensureSocialAccountLimit($workspace); $existingAccount = $workspace->socialAccounts() ->where('platform', $this->platform->value) diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 67ad3a6d..b53645d5 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -44,6 +44,7 @@ public function connect(Request $request): Response|RedirectResponse } $this->authorize('manageAccounts', $workspace); + $this->ensureSocialAccountLimit($workspace); $existingAccount = $workspace->socialAccounts() ->where('platform', $this->platform->value) diff --git a/app/Http/Controllers/Auth/LinkedInPageController.php b/app/Http/Controllers/Auth/LinkedInPageController.php index d983cc47..85373f92 100644 --- a/app/Http/Controllers/Auth/LinkedInPageController.php +++ b/app/Http/Controllers/Auth/LinkedInPageController.php @@ -45,6 +45,7 @@ public function connect(Request $request): SymfonyResponse|RedirectResponse } $this->authorize('manageAccounts', $workspace); + $this->ensureSocialAccountLimit($workspace); $existingAccount = $workspace->socialAccounts() ->where('platform', $this->platform->value) diff --git a/app/Http/Controllers/Auth/MastodonController.php b/app/Http/Controllers/Auth/MastodonController.php index c0bbbce6..78a5a9cb 100644 --- a/app/Http/Controllers/Auth/MastodonController.php +++ b/app/Http/Controllers/Auth/MastodonController.php @@ -36,6 +36,7 @@ public function connect(Request $request): Response|RedirectResponse } $this->authorize('manageAccounts', $workspace); + $this->ensureSocialAccountLimit($workspace); return Inertia::render('accounts/MastodonConnect', [ 'errors' => session('errors')?->getBag('default')?->toArray() ?? [], diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index 3d7e6fea..93fae5ce 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -7,6 +7,7 @@ use App\Actions\SocialAccount\ToggleSocialAccount; use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; +use App\Features\SocialAccountLimit; use App\Http\Controllers\Controller; use App\Models\SocialAccount; use App\Models\Workspace; @@ -16,6 +17,7 @@ use Illuminate\View\View; use Inertia\Inertia; use Inertia\Response; +use Laravel\Pennant\Feature; use Laravel\Socialite\Facades\Socialite; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; @@ -26,7 +28,20 @@ class SocialController extends Controller protected function ensurePlatformEnabled(): void { if (isset($this->platform) && ! $this->platform->isEnabled()) { - abort(403, 'This platform is currently unavailable.'); + abort(SymfonyResponse::HTTP_FORBIDDEN, 'This platform is currently unavailable.'); + } + } + + protected function ensureSocialAccountLimit(Workspace $workspace): void + { + if (config('trypost.self_hosted')) { + return; + } + + $limit = Feature::for($workspace)->value(SocialAccountLimit::class); + + if ($workspace->socialAccounts()->count() >= $limit) { + abort(SymfonyResponse::HTTP_FORBIDDEN, __('accounts.limit_reached')); } } @@ -113,6 +128,8 @@ protected function redirectToProvider(Request $request, string $driver, array $s return redirect()->route('app.workspaces.create'); } + $this->ensureSocialAccountLimit($workspace); + session(['social_connect_workspace' => $workspace->id]); session(['social_connect_onboarding' => $request->boolean('onboarding')]); diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index b465229a..18aa8f53 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -36,6 +36,7 @@ public function connect(Request $request): Response|RedirectResponse } $this->authorize('manageAccounts', $workspace); + $this->ensureSocialAccountLimit($workspace); $existingAccount = $workspace->socialAccounts() ->where('platform', $this->platform->value) diff --git a/app/Http/Controllers/Auth/YouTubeController.php b/app/Http/Controllers/Auth/YouTubeController.php index 4786d536..0155b12f 100644 --- a/app/Http/Controllers/Auth/YouTubeController.php +++ b/app/Http/Controllers/Auth/YouTubeController.php @@ -40,6 +40,7 @@ public function connect(Request $request): Response|RedirectResponse } $this->authorize('manageAccounts', $workspace); + $this->ensureSocialAccountLimit($workspace); $existingAccount = $workspace->socialAccounts() ->where('platform', $this->platform->value) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 725cd830..b1cfd02f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -41,6 +41,7 @@ use Laravel\Cashier\Events\WebhookReceived; use Laravel\Nightwatch\Facades\Nightwatch; use Laravel\Nightwatch\Records\CacheEvent; +use Laravel\Pennant\Feature; use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Two\GoogleProvider; use PostHog\PostHog; @@ -78,6 +79,10 @@ public function boot(): void Cashier::useCustomerModel(Workspace::class); Cashier::useSubscriptionModel(Subscription::class); Cashier::useSubscriptionItemModel(SubscriptionItem::class); + + Feature::resolveScopeUsing(fn () => auth()->user()?->currentWorkspace); + Feature::useMorphMap(); + Feature::discover(); } protected function configureMorphMap(): void diff --git a/boost.json b/boost.json index 118561de..72af289a 100644 --- a/boost.json +++ b/boost.json @@ -14,6 +14,7 @@ "laravel-best-practices", "configuring-horizon", "mcp-development", + "pennant-development", "socialite-development", "wayfinder-development", "pest-testing", diff --git a/composer.json b/composer.json index f92e6c54..a5d6aee2 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ "laravel/horizon": "^5.42", "laravel/mcp": "^0.6.4", "laravel/nightwatch": "^1.22", + "laravel/pennant": "^1.23", "laravel/reverb": "^1.0", "laravel/socialite": "^5.24", "laravel/tinker": "^3.0", diff --git a/composer.lock b/composer.lock index b5ab8abf..1323a1a2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "99d4e3356781b4df8847d654815dd664", + "content-hash": "b0364b1a2f51fe26e18157d435efe5ab", "packages": [ { "name": "aws/aws-crt-php", @@ -2529,6 +2529,82 @@ }, "time": "2026-03-18T23:25:05+00:00" }, + { + "name": "laravel/pennant", + "version": "v1.23.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pennant.git", + "reference": "d3d531d0ba640f9d0bd3580990fb205244e956ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pennant/zipball/d3d531d0ba640f9d0bd3580990fb205244e956ca", + "reference": "d3d531d0ba640f9d0bd3580990fb205244e956ca", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/container": "^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/finder": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/octane": "^1.4|^2.0", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Feature": "Laravel\\Pennant\\Feature" + }, + "providers": [ + "Laravel\\Pennant\\PennantServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Pennant\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "A simple, lightweight library for managing feature flags.", + "homepage": "https://github.com/laravel/pennant", + "keywords": [ + "feature", + "flags", + "laravel", + "pennant" + ], + "support": { + "issues": "https://github.com/laravel/pennant/issues", + "source": "https://github.com/laravel/pennant" + }, + "time": "2026-03-19T02:27:39+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.16", diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 6b33373d..f3ddfe59 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -5,6 +5,8 @@ 'page_title' => 'Connected Accounts', 'description' => 'Connect your social networks to schedule and publish posts', + 'limit_reached' => 'You have reached your plan limit for social accounts.', + 'not_connected' => 'Not connected', 'connect' => 'Connect', 'connection_lost' => 'Connection lost', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index b4247d22..ef9e19e4 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -5,6 +5,8 @@ 'page_title' => 'Cuentas conectadas', 'description' => 'Conecta tus redes sociales para programar y publicar posts', + 'limit_reached' => 'Has alcanzado el límite de cuentas sociales de tu plan.', + 'not_connected' => 'No conectado', 'connect' => 'Conectar', 'connection_lost' => 'Conexión perdida', diff --git a/lang/pt-br/accounts.php b/lang/pt-br/accounts.php index 508bfc33..0e3e675b 100644 --- a/lang/pt-br/accounts.php +++ b/lang/pt-br/accounts.php @@ -5,6 +5,8 @@ 'page_title' => 'Contas Conectadas', 'description' => 'Conecte suas redes sociais para agendar e publicar posts', + 'limit_reached' => 'Você atingiu o limite de contas sociais do seu plano.', + 'not_connected' => 'Não conectado', 'connect' => 'Conectar', 'connection_lost' => 'Conexão perdida', diff --git a/tests/Feature/StripeEventListenerTest.php b/tests/Feature/StripeEventListenerTest.php new file mode 100644 index 00000000..3a0a69cd --- /dev/null +++ b/tests/Feature/StripeEventListenerTest.php @@ -0,0 +1,62 @@ + true]); + + $this->user = User::factory()->create(['setup' => Setup::Subscription]); + $this->workspace = Workspace::factory()->create([ + 'user_id' => $this->user->id, + 'stripe_id' => 'cus_test_'.fake()->uuid(), + ]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +test('webhook creates subscription and completes setup', function () { + $payload = [ + 'type' => 'customer.subscription.created', + 'data' => [ + 'object' => [ + 'customer' => $this->workspace->stripe_id, + 'id' => 'sub_test_'.fake()->uuid(), + 'status' => 'active', + ], + ], + ]; + + $event = new WebhookReceived($payload); + + $listener = new StripeEventListener; + $listener->handle($event); + + expect($this->user->fresh()->setup)->toBe(Setup::Completed); +}); + +test('webhook ignores unknown stripe customer', function () { + $payload = [ + 'type' => 'customer.subscription.created', + 'data' => [ + 'object' => [ + 'customer' => 'cus_unknown_'.fake()->uuid(), + 'id' => 'sub_test_'.fake()->uuid(), + 'status' => 'active', + ], + ], + ]; + + $event = new WebhookReceived($payload); + + $listener = new StripeEventListener; + $listener->handle($event); + + expect($this->user->fresh()->setup)->toBe(Setup::Subscription); +}); diff --git a/tests/Unit/Features/AiImagesLimitTest.php b/tests/Unit/Features/AiImagesLimitTest.php new file mode 100644 index 00000000..e2537640 --- /dev/null +++ b/tests/Unit/Features/AiImagesLimitTest.php @@ -0,0 +1,22 @@ + 200]); + $workspace = new Workspace; + $workspace->setRelation('plan', $plan); + + expect((new AiImagesLimit)->resolve($workspace))->toBe(200); +}); + +test('falls back to 50 when no plan', function () { + $workspace = new Workspace; + $workspace->setRelation('plan', null); + + expect((new AiImagesLimit)->resolve($workspace))->toBe(50); +}); diff --git a/tests/Unit/Features/AiVideosLimitTest.php b/tests/Unit/Features/AiVideosLimitTest.php new file mode 100644 index 00000000..f8a4a732 --- /dev/null +++ b/tests/Unit/Features/AiVideosLimitTest.php @@ -0,0 +1,22 @@ + 50]); + $workspace = new Workspace; + $workspace->setRelation('plan', $plan); + + expect((new AiVideosLimit)->resolve($workspace))->toBe(50); +}); + +test('falls back to 10 when no plan', function () { + $workspace = new Workspace; + $workspace->setRelation('plan', null); + + expect((new AiVideosLimit)->resolve($workspace))->toBe(10); +}); diff --git a/tests/Unit/Features/BrandLimitTest.php b/tests/Unit/Features/BrandLimitTest.php new file mode 100644 index 00000000..59a29e90 --- /dev/null +++ b/tests/Unit/Features/BrandLimitTest.php @@ -0,0 +1,22 @@ + 5]); + $workspace = new Workspace; + $workspace->setRelation('plan', $plan); + + expect((new BrandLimit)->resolve($workspace))->toBe(5); +}); + +test('falls back to 0 when no plan', function () { + $workspace = new Workspace; + $workspace->setRelation('plan', null); + + expect((new BrandLimit)->resolve($workspace))->toBe(0); +}); diff --git a/tests/Unit/Features/DataRetentionDaysTest.php b/tests/Unit/Features/DataRetentionDaysTest.php new file mode 100644 index 00000000..62d89d5b --- /dev/null +++ b/tests/Unit/Features/DataRetentionDaysTest.php @@ -0,0 +1,22 @@ + 365]); + $workspace = new Workspace; + $workspace->setRelation('plan', $plan); + + expect((new DataRetentionDays)->resolve($workspace))->toBe(365); +}); + +test('falls back to 30 when no plan', function () { + $workspace = new Workspace; + $workspace->setRelation('plan', null); + + expect((new DataRetentionDays)->resolve($workspace))->toBe(30); +}); diff --git a/tests/Unit/Features/MemberLimitTest.php b/tests/Unit/Features/MemberLimitTest.php new file mode 100644 index 00000000..07466e50 --- /dev/null +++ b/tests/Unit/Features/MemberLimitTest.php @@ -0,0 +1,22 @@ + 10]); + $workspace = new Workspace; + $workspace->setRelation('plan', $plan); + + expect((new MemberLimit)->resolve($workspace))->toBe(10); +}); + +test('falls back to 1 when no plan', function () { + $workspace = new Workspace; + $workspace->setRelation('plan', null); + + expect((new MemberLimit)->resolve($workspace))->toBe(1); +}); diff --git a/tests/Unit/Features/SocialAccountLimitTest.php b/tests/Unit/Features/SocialAccountLimitTest.php new file mode 100644 index 00000000..af0aff47 --- /dev/null +++ b/tests/Unit/Features/SocialAccountLimitTest.php @@ -0,0 +1,22 @@ + 30]); + $workspace = new Workspace; + $workspace->setRelation('plan', $plan); + + expect((new SocialAccountLimit)->resolve($workspace))->toBe(30); +}); + +test('falls back to 5 when no plan', function () { + $workspace = new Workspace; + $workspace->setRelation('plan', null); + + expect((new SocialAccountLimit)->resolve($workspace))->toBe(5); +});