From bc9ffd74d3ce238f73c5ee9b49f2916f51c8750c Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 26 Jan 2026 21:08:59 +0000 Subject: [PATCH] monorepo sepration --- .env.example | 76 -- CLAUDE.md | 115 +-- README.md | 182 ++-- app/Http/Controllers/.gitkeep | 0 app/Mod/.gitkeep | 0 app/Models/.gitkeep | 0 app/Providers/AppServiceProvider.php | 24 - artisan | 15 - bootstrap/app.php | 26 - bootstrap/cache/.gitignore | 2 - bootstrap/providers.php | 5 - composer.json | 62 +- config/core.php | 24 - database/factories/.gitkeep | 0 database/migrations/.gitkeep | 0 database/seeders/DatabaseSeeder.php | 16 - package.json | 16 - phpunit.xml | 2 +- postcss.config.js | 6 - public/.htaccess | 21 - public/index.php | 17 - public/robots.txt | 2 - resources/css/app.css | 3 - resources/js/app.js | 1 - resources/js/bootstrap.js | 3 - resources/views/welcome.blade.php | 65 -- routes/api.php | 5 - routes/console.php | 3 - routes/web.php | 7 - src/Boot.php | 173 ++++ src/Concerns/BelongsToNamespace.php | 247 +++++ src/Concerns/BelongsToWorkspace.php | 349 +++++++ src/Concerns/HasWorkspaceCache.php | 272 ++++++ src/Concerns/TwoFactorAuthenticatable.php | 250 +++++ src/Console/Commands/CheckUsageAlerts.php | 261 +++++ .../Commands/ProcessAccountDeletions.php | 82 ++ src/Console/Commands/RefreshUserStats.php | 56 ++ src/Console/Commands/ResetBillingCycles.php | 411 ++++++++ src/Contracts/EntitlementWebhookEvent.php | 37 + .../TwoFactorAuthenticationProvider.php | 36 + .../Api/EntitlementWebhookController.php | 255 +++++ src/Controllers/EntitlementApiController.php | 493 ++++++++++ src/Controllers/ReferralController.php | 138 +++ src/Controllers/WorkspaceController.php | 277 ++++++ .../WorkspaceInvitationController.php | 68 ++ src/Database/Factories/UserFactory.php | 73 ++ src/Database/Factories/UserTokenFactory.php | 87 ++ .../Factories/WaitlistEntryFactory.php | 59 ++ src/Database/Factories/WorkspaceFactory.php | 81 ++ .../Factories/WorkspaceInvitationFactory.php | 75 ++ src/Database/Seeders/DemoTestUserSeeder.php | 170 ++++ src/Database/Seeders/DemoWorkspaceSeeder.php | 165 ++++ src/Database/Seeders/FeatureSeeder.php | 901 ++++++++++++++++++ .../Seeders/SystemWorkspaceSeeder.php | 57 ++ src/Database/Seeders/WorkspaceSeeder.php | 183 ++++ src/Enums/UserTier.php | 81 ++ src/Enums/WebhookDeliveryStatus.php | 12 + src/Events/Webhook/BoostActivatedEvent.php | 58 ++ src/Events/Webhook/BoostExpiredEvent.php | 58 ++ src/Events/Webhook/LimitReachedEvent.php | 52 + src/Events/Webhook/LimitWarningEvent.php | 56 ++ src/Events/Webhook/PackageChangedEvent.php | 67 ++ src/Exceptions/EntitlementException.php | 46 + .../MissingWorkspaceContextException.php | 133 +++ src/Features/ApolloTier.php | 79 ++ src/Features/BetaFeatures.php | 42 + src/Features/HadesTier.php | 70 ++ src/Features/UnlimitedWorkspaces.php | 75 ++ src/Jobs/ComputeUserStats.php | 43 + src/Jobs/DispatchEntitlementWebhook.php | 188 ++++ src/Jobs/ProcessAccountDeletion.php | 130 +++ src/Lang/en_GB/tenant.php | 567 +++++++++++ src/Listeners/SendWelcomeEmail.php | 21 + src/Mail/AccountDeletionRequested.php | 62 ++ src/Middleware/CheckWorkspacePermission.php | 96 ++ src/Middleware/RequireAdminDomain.php | 34 + src/Middleware/RequireWorkspaceContext.php | 118 +++ src/Middleware/ResolveNamespace.php | 59 ++ .../ResolveWorkspaceFromSubdomain.php | 142 +++ ...0001_01_01_000000_create_tenant_tables.php | 316 ++++++ ...000_create_workspace_invitations_table.php | 37 + ...20000_create_usage_alert_history_table.php | 35 + ...000_create_entitlement_webhooks_tables.php | 63 ++ ...26_140000_create_workspace_teams_table.php | 59 ++ src/Models/AccountDeletionRequest.php | 160 ++++ src/Models/AgentReferralBonus.php | 110 +++ src/Models/Boost.php | 220 +++++ src/Models/EntitlementLog.php | 207 ++++ src/Models/EntitlementWebhook.php | 245 +++++ src/Models/EntitlementWebhookDelivery.php | 139 +++ src/Models/Feature.php | 159 ++++ src/Models/NamespacePackage.php | 176 ++++ src/Models/Namespace_.php | 321 +++++++ src/Models/Package.php | 244 +++++ src/Models/UsageAlertHistory.php | 198 ++++ src/Models/UsageRecord.php | 121 +++ src/Models/User.php | 596 ++++++++++++ src/Models/UserToken.php | 126 +++ src/Models/UserTwoFactorAuth.php | 38 + src/Models/WaitlistEntry.php | 126 +++ src/Models/Workspace.php | 834 ++++++++++++++++ src/Models/WorkspaceInvitation.php | 168 ++++ src/Models/WorkspaceMember.php | 377 ++++++++ src/Models/WorkspacePackage.php | 164 ++++ src/Models/WorkspaceTeam.php | 517 ++++++++++ .../BoostExpiredNotification.php | 144 +++ src/Notifications/UsageAlertNotification.php | 162 ++++ .../WaitlistInviteNotification.php | 69 ++ src/Notifications/WelcomeNotification.php | 57 ++ .../WorkspaceInvitationNotification.php | 65 ++ src/Routes/api.php | 82 ++ src/Routes/web.php | 59 ++ src/Rules/CheckUserPasswordRule.php | 45 + src/Rules/ResourceStatusRule.php | 39 + src/Scopes/WorkspaceScope.php | 174 ++++ src/Services/EntitlementResult.php | 174 ++++ src/Services/EntitlementService.php | 821 ++++++++++++++++ src/Services/EntitlementWebhookService.php | 361 +++++++ src/Services/NamespaceManager.php | 278 ++++++ src/Services/NamespaceService.php | 288 ++++++ src/Services/TotpService.php | 194 ++++ src/Services/UsageAlertService.php | 356 +++++++ src/Services/UserStatsService.php | 284 ++++++ src/Services/WorkspaceCacheManager.php | 458 +++++++++ src/Services/WorkspaceManager.php | 221 +++++ src/Services/WorkspaceService.php | 156 +++ src/Services/WorkspaceTeamService.php | 629 ++++++++++++ .../entitlement-webhook-manager.blade.php | 401 ++++++++ .../Blade/admin/workspace-details.blade.php | 696 ++++++++++++++ .../Blade/admin/workspace-manager.blade.php | 417 ++++++++ .../account-deletion-requested.blade.php | 44 + src/View/Blade/emails/usage-alert.blade.php | 60 ++ .../web/account/cancel-deletion.blade.php | 28 + .../web/account/confirm-deletion.blade.php | 220 +++++ src/View/Blade/web/workspace/home.blade.php | 156 +++ .../Modal/Admin/EntitlementWebhookManager.php | 356 +++++++ src/View/Modal/Admin/WorkspaceDetails.php | 584 ++++++++++++ src/View/Modal/Admin/WorkspaceManager.php | 666 +++++++++++++ src/View/Modal/Web/CancelDeletion.php | 36 + src/View/Modal/Web/ConfirmDeletion.php | 116 +++ src/View/Modal/Web/WorkspaceHome.php | 67 ++ storage/app/.gitignore | 3 - storage/app/public/.gitignore | 2 - storage/framework/.gitignore | 9 - storage/framework/cache/.gitignore | 3 - storage/framework/cache/data/.gitignore | 2 - storage/framework/sessions/.gitignore | 2 - storage/framework/testing/.gitignore | 2 - storage/framework/views/.gitignore | 2 - storage/logs/.gitignore | 2 - tailwind.config.js | 11 - tests/Feature/.gitkeep | 0 tests/Feature/AccountDeletionTest.php | 334 +++++++ tests/Feature/AuthenticationTest.php | 124 +++ tests/Feature/EntitlementApiTest.php | 251 +++++ tests/Feature/EntitlementServiceTest.php | 641 +++++++++++++ tests/Feature/Guards/AccessTokenGuardTest.php | 180 ++++ tests/Feature/ProfileTest.php | 131 +++ tests/Feature/ResetBillingCyclesTest.php | 462 +++++++++ tests/Feature/SettingsTest.php | 215 +++++ .../Feature/TwoFactorAuthenticatableTest.php | 334 +++++++ tests/Feature/UsageAlertServiceTest.php | 261 +++++ tests/Feature/WaitlistTest.php | 181 ++++ tests/Feature/WorkspaceCacheTest.php | 584 ++++++++++++ tests/Feature/WorkspaceInvitationTest.php | 192 ++++ tests/Feature/WorkspaceSecurityTest.php | 433 +++++++++ tests/Feature/WorkspaceTenancyTest.php | 165 ++++ tests/TestCase.php | 10 - tests/Unit/.gitkeep | 0 vite.config.js | 11 - 170 files changed, 26922 insertions(+), 587 deletions(-) delete mode 100644 .env.example delete mode 100644 app/Http/Controllers/.gitkeep delete mode 100644 app/Mod/.gitkeep delete mode 100644 app/Models/.gitkeep delete mode 100644 app/Providers/AppServiceProvider.php delete mode 100755 artisan delete mode 100644 bootstrap/app.php delete mode 100644 bootstrap/cache/.gitignore delete mode 100644 bootstrap/providers.php delete mode 100644 config/core.php delete mode 100644 database/factories/.gitkeep delete mode 100644 database/migrations/.gitkeep delete mode 100644 database/seeders/DatabaseSeeder.php delete mode 100644 package.json delete mode 100644 postcss.config.js delete mode 100644 public/.htaccess delete mode 100644 public/index.php delete mode 100644 public/robots.txt delete mode 100644 resources/css/app.css delete mode 100644 resources/js/app.js delete mode 100644 resources/js/bootstrap.js delete mode 100644 resources/views/welcome.blade.php delete mode 100644 routes/api.php delete mode 100644 routes/console.php delete mode 100644 routes/web.php create mode 100644 src/Boot.php create mode 100644 src/Concerns/BelongsToNamespace.php create mode 100644 src/Concerns/BelongsToWorkspace.php create mode 100644 src/Concerns/HasWorkspaceCache.php create mode 100644 src/Concerns/TwoFactorAuthenticatable.php create mode 100644 src/Console/Commands/CheckUsageAlerts.php create mode 100644 src/Console/Commands/ProcessAccountDeletions.php create mode 100644 src/Console/Commands/RefreshUserStats.php create mode 100644 src/Console/Commands/ResetBillingCycles.php create mode 100644 src/Contracts/EntitlementWebhookEvent.php create mode 100644 src/Contracts/TwoFactorAuthenticationProvider.php create mode 100644 src/Controllers/Api/EntitlementWebhookController.php create mode 100644 src/Controllers/EntitlementApiController.php create mode 100644 src/Controllers/ReferralController.php create mode 100644 src/Controllers/WorkspaceController.php create mode 100644 src/Controllers/WorkspaceInvitationController.php create mode 100644 src/Database/Factories/UserFactory.php create mode 100644 src/Database/Factories/UserTokenFactory.php create mode 100644 src/Database/Factories/WaitlistEntryFactory.php create mode 100644 src/Database/Factories/WorkspaceFactory.php create mode 100644 src/Database/Factories/WorkspaceInvitationFactory.php create mode 100644 src/Database/Seeders/DemoTestUserSeeder.php create mode 100644 src/Database/Seeders/DemoWorkspaceSeeder.php create mode 100644 src/Database/Seeders/FeatureSeeder.php create mode 100644 src/Database/Seeders/SystemWorkspaceSeeder.php create mode 100644 src/Database/Seeders/WorkspaceSeeder.php create mode 100644 src/Enums/UserTier.php create mode 100644 src/Enums/WebhookDeliveryStatus.php create mode 100644 src/Events/Webhook/BoostActivatedEvent.php create mode 100644 src/Events/Webhook/BoostExpiredEvent.php create mode 100644 src/Events/Webhook/LimitReachedEvent.php create mode 100644 src/Events/Webhook/LimitWarningEvent.php create mode 100644 src/Events/Webhook/PackageChangedEvent.php create mode 100644 src/Exceptions/EntitlementException.php create mode 100644 src/Exceptions/MissingWorkspaceContextException.php create mode 100644 src/Features/ApolloTier.php create mode 100644 src/Features/BetaFeatures.php create mode 100644 src/Features/HadesTier.php create mode 100644 src/Features/UnlimitedWorkspaces.php create mode 100644 src/Jobs/ComputeUserStats.php create mode 100644 src/Jobs/DispatchEntitlementWebhook.php create mode 100644 src/Jobs/ProcessAccountDeletion.php create mode 100644 src/Lang/en_GB/tenant.php create mode 100644 src/Listeners/SendWelcomeEmail.php create mode 100644 src/Mail/AccountDeletionRequested.php create mode 100644 src/Middleware/CheckWorkspacePermission.php create mode 100644 src/Middleware/RequireAdminDomain.php create mode 100644 src/Middleware/RequireWorkspaceContext.php create mode 100644 src/Middleware/ResolveNamespace.php create mode 100644 src/Middleware/ResolveWorkspaceFromSubdomain.php create mode 100644 src/Migrations/0001_01_01_000000_create_tenant_tables.php create mode 100644 src/Migrations/2026_01_26_000000_create_workspace_invitations_table.php create mode 100644 src/Migrations/2026_01_26_120000_create_usage_alert_history_table.php create mode 100644 src/Migrations/2026_01_26_140000_create_entitlement_webhooks_tables.php create mode 100644 src/Migrations/2026_01_26_140000_create_workspace_teams_table.php create mode 100644 src/Models/AccountDeletionRequest.php create mode 100644 src/Models/AgentReferralBonus.php create mode 100644 src/Models/Boost.php create mode 100644 src/Models/EntitlementLog.php create mode 100644 src/Models/EntitlementWebhook.php create mode 100644 src/Models/EntitlementWebhookDelivery.php create mode 100644 src/Models/Feature.php create mode 100644 src/Models/NamespacePackage.php create mode 100644 src/Models/Namespace_.php create mode 100644 src/Models/Package.php create mode 100644 src/Models/UsageAlertHistory.php create mode 100644 src/Models/UsageRecord.php create mode 100644 src/Models/User.php create mode 100644 src/Models/UserToken.php create mode 100644 src/Models/UserTwoFactorAuth.php create mode 100644 src/Models/WaitlistEntry.php create mode 100644 src/Models/Workspace.php create mode 100644 src/Models/WorkspaceInvitation.php create mode 100644 src/Models/WorkspaceMember.php create mode 100644 src/Models/WorkspacePackage.php create mode 100644 src/Models/WorkspaceTeam.php create mode 100644 src/Notifications/BoostExpiredNotification.php create mode 100644 src/Notifications/UsageAlertNotification.php create mode 100644 src/Notifications/WaitlistInviteNotification.php create mode 100644 src/Notifications/WelcomeNotification.php create mode 100644 src/Notifications/WorkspaceInvitationNotification.php create mode 100644 src/Routes/api.php create mode 100644 src/Routes/web.php create mode 100644 src/Rules/CheckUserPasswordRule.php create mode 100644 src/Rules/ResourceStatusRule.php create mode 100644 src/Scopes/WorkspaceScope.php create mode 100644 src/Services/EntitlementResult.php create mode 100644 src/Services/EntitlementService.php create mode 100644 src/Services/EntitlementWebhookService.php create mode 100644 src/Services/NamespaceManager.php create mode 100644 src/Services/NamespaceService.php create mode 100644 src/Services/TotpService.php create mode 100644 src/Services/UsageAlertService.php create mode 100644 src/Services/UserStatsService.php create mode 100644 src/Services/WorkspaceCacheManager.php create mode 100644 src/Services/WorkspaceManager.php create mode 100644 src/Services/WorkspaceService.php create mode 100644 src/Services/WorkspaceTeamService.php create mode 100644 src/View/Blade/admin/entitlement-webhook-manager.blade.php create mode 100644 src/View/Blade/admin/workspace-details.blade.php create mode 100644 src/View/Blade/admin/workspace-manager.blade.php create mode 100644 src/View/Blade/emails/account-deletion-requested.blade.php create mode 100644 src/View/Blade/emails/usage-alert.blade.php create mode 100644 src/View/Blade/web/account/cancel-deletion.blade.php create mode 100644 src/View/Blade/web/account/confirm-deletion.blade.php create mode 100644 src/View/Blade/web/workspace/home.blade.php create mode 100644 src/View/Modal/Admin/EntitlementWebhookManager.php create mode 100644 src/View/Modal/Admin/WorkspaceDetails.php create mode 100644 src/View/Modal/Admin/WorkspaceManager.php create mode 100644 src/View/Modal/Web/CancelDeletion.php create mode 100644 src/View/Modal/Web/ConfirmDeletion.php create mode 100644 src/View/Modal/Web/WorkspaceHome.php delete mode 100644 storage/app/.gitignore delete mode 100644 storage/app/public/.gitignore delete mode 100644 storage/framework/.gitignore delete mode 100644 storage/framework/cache/.gitignore delete mode 100644 storage/framework/cache/data/.gitignore delete mode 100644 storage/framework/sessions/.gitignore delete mode 100644 storage/framework/testing/.gitignore delete mode 100644 storage/framework/views/.gitignore delete mode 100644 storage/logs/.gitignore delete mode 100644 tailwind.config.js delete mode 100644 tests/Feature/.gitkeep create mode 100644 tests/Feature/AccountDeletionTest.php create mode 100644 tests/Feature/AuthenticationTest.php create mode 100644 tests/Feature/EntitlementApiTest.php create mode 100644 tests/Feature/EntitlementServiceTest.php create mode 100644 tests/Feature/Guards/AccessTokenGuardTest.php create mode 100644 tests/Feature/ProfileTest.php create mode 100644 tests/Feature/ResetBillingCyclesTest.php create mode 100644 tests/Feature/SettingsTest.php create mode 100644 tests/Feature/TwoFactorAuthenticatableTest.php create mode 100644 tests/Feature/UsageAlertServiceTest.php create mode 100644 tests/Feature/WaitlistTest.php create mode 100644 tests/Feature/WorkspaceCacheTest.php create mode 100644 tests/Feature/WorkspaceInvitationTest.php create mode 100644 tests/Feature/WorkspaceSecurityTest.php create mode 100644 tests/Feature/WorkspaceTenancyTest.php delete mode 100644 tests/TestCase.php delete mode 100644 tests/Unit/.gitkeep delete mode 100644 vite.config.js diff --git a/.env.example b/.env.example deleted file mode 100644 index 01b4da4..0000000 --- a/.env.example +++ /dev/null @@ -1,76 +0,0 @@ -APP_NAME="Core PHP App" -APP_ENV=local -APP_KEY= -APP_DEBUG=true -APP_TIMEZONE=UTC -APP_URL=http://localhost - -APP_LOCALE=en_GB -APP_FALLBACK_LOCALE=en_GB -APP_FAKER_LOCALE=en_GB - -APP_MAINTENANCE_DRIVER=file - -BCRYPT_ROUNDS=12 - -LOG_CHANNEL=stack -LOG_STACK=single -LOG_DEPRECATIONS_CHANNEL=null -LOG_LEVEL=debug - -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=core -# DB_USERNAME=root -# DB_PASSWORD= - -SESSION_DRIVER=database -SESSION_LIFETIME=120 -SESSION_ENCRYPT=false -SESSION_PATH=/ -SESSION_DOMAIN=null - -BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local -QUEUE_CONNECTION=database - -CACHE_STORE=database -CACHE_PREFIX= - -MEMCACHED_HOST=127.0.0.1 - -REDIS_CLIENT=phpredis -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 - -MAIL_MAILER=log -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" - -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false - -VITE_APP_NAME="${APP_NAME}" - -# Core PHP Framework -CORE_CACHE_DISCOVERY=true - -# CDN Configuration (optional) -CDN_ENABLED=false -CDN_DRIVER=bunny -BUNNYCDN_API_KEY= -BUNNYCDN_STORAGE_ZONE= -BUNNYCDN_PULL_ZONE= - -# Flux Pro (optional) -FLUX_LICENSE_KEY= diff --git a/CLAUDE.md b/CLAUDE.md index 138e17b..385d54b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,66 +1,73 @@ -# Core PHP Framework Project +# Core Tenant + +Multi-tenancy module for Core PHP Framework. + +## Quick Reference + +```bash +composer test # Run tests +composer pint # Fix code style +``` ## Architecture -Modular monolith using Core PHP Framework. Modules live in `app/Mod/{Name}/Boot.php`. +This module provides the multi-tenancy foundation: -**Event-driven registration:** -```php -class Boot -{ - public static array $listens = [ - WebRoutesRegistering::class => 'onWebRoutes', - ApiRoutesRegistering::class => 'onApiRoutes', - AdminPanelBooting::class => 'onAdminPanel', - ]; -} +- **Users** - Application users with 2FA support +- **Workspaces** - Tenant boundaries with team members +- **Entitlements** - Feature access, packages, usage tracking +- **Account Management** - Settings, scheduled deletions + +### Key Services + +| Service | Purpose | +|---------|---------| +| `WorkspaceManager` | Current workspace context | +| `WorkspaceService` | Workspace CRUD operations | +| `EntitlementService` | Feature access & usage | +| `UserStatsService` | User statistics | +| `UsageAlertService` | Usage threshold alerts | + +### Models + +``` +src/Models/ +├── User.php # Application user +├── Workspace.php # Tenant workspace +├── WorkspaceMember.php # Membership with roles +├── Entitlement.php # Feature entitlements +├── UsageRecord.php # Usage tracking +└── Referral.php # Referral tracking ``` -## Commands +### Middleware + +- `RequireAdminDomain` - Restrict to admin domain +- `CheckWorkspacePermission` - Permission-based access + +## Event Listeners + +```php +public static array $listens = [ + AdminPanelBooting::class => 'onAdminPanel', + ApiRoutesRegistering::class => 'onApiRoutes', + WebRoutesRegistering::class => 'onWebRoutes', + ConsoleBooting::class => 'onConsole', +]; +``` + +## Namespace + +All classes use `Core\Mod\Tenant\` namespace. + +## Testing + +Tests use Orchestra Testbench. Run with: ```bash -composer run dev # Dev server (if configured) -php artisan serve # Laravel dev server -npm run dev # Vite -./vendor/bin/pint --dirty # Format changed files -php artisan test # All tests -php artisan make:mod Blog # Create module +composer test ``` -## Module Structure - -``` -app/Mod/Blog/ -├── Boot.php # Event listeners -├── Models/ # Eloquent models -├── Routes/ -│ ├── web.php # Web routes -│ └── api.php # API routes -├── Views/ # Blade templates -├── Livewire/ # Livewire components -├── Migrations/ # Database migrations -└── Tests/ # Module tests -``` - -## Packages - -| Package | Purpose | -|---------|---------| -| `host-uk/core` | Core framework, events, module discovery | -| `host-uk/core-admin` | Admin panel, Livewire modals | -| `host-uk/core-api` | REST API, scopes, rate limiting, webhooks | -| `host-uk/core-mcp` | Model Context Protocol for AI agents | - -## Conventions - -- UK English (colour, organisation, centre) -- PSR-12 coding style (Laravel Pint) -- Pest for testing -- Livewire + Flux Pro for UI - ## License -- `Core\` namespace and vendor packages: EUPL-1.2 (copyleft) -- `app/Mod/*`, `app/Website/*`: Your choice (no copyleft) - -See LICENSE for full details. +EUPL-1.2 (copyleft, GPL-compatible). diff --git a/README.md b/README.md index 5db97d9..4785c04 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,131 @@ -# Core PHP Framework Project +# Core Tenant -[![CI](https://github.com/host-uk/core-template/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/core-template/actions/workflows/ci.yml) -[![codecov](https://codecov.io/gh/host-uk/core-template/graph/badge.svg)](https://codecov.io/gh/host-uk/core-template) -[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/core-template)](https://packagist.org/packages/host-uk/core-template) -[![Laravel](https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel)](https://laravel.com) +[![CI](https://github.com/host-uk/core-tenant/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/core-tenant/actions/workflows/ci.yml) +[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/core-tenant)](https://packagist.org/packages/host-uk/core-tenant) +[![Laravel](https://img.shields.io/badge/Laravel-11.x%20%7C%2012.x-FF2D20?logo=laravel)](https://laravel.com) [![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE) -A modular monolith Laravel application built with Core PHP Framework. +Multi-tenancy module for the Core PHP Framework providing users, workspaces, and entitlements. ## Features -- **Core Framework** - Event-driven module system with lazy loading -- **Admin Panel** - Livewire-powered admin interface with Flux UI -- **REST API** - Scoped API keys, rate limiting, webhooks, OpenAPI docs -- **MCP Tools** - Model Context Protocol for AI agent integration +- **Users & Authentication** - User management with 2FA support +- **Workspaces** - Multi-tenant workspace boundaries +- **Entitlements** - Feature access, packages, and usage tracking +- **Account Management** - User settings, account deletion +- **Referrals** - Referral system support +- **Usage Alerts** - Configurable usage threshold alerts ## Requirements - PHP 8.2+ -- Composer 2.x -- SQLite (default) or MySQL/PostgreSQL -- Node.js 18+ (for frontend assets) +- Laravel 11.x or 12.x +- Core PHP Framework (`host-uk/core`) ## Installation ```bash -# Clone or create from template -git clone https://github.com/host-uk/core-template.git my-project -cd my-project - -# Install dependencies -composer install -npm install - -# Configure environment -cp .env.example .env -php artisan key:generate - -# Set up database -touch database/database.sqlite -php artisan migrate - -# Start development server -php artisan serve +composer require host-uk/core-tenant ``` -Visit: http://localhost:8000 +The service provider will be auto-discovered. -## Project Structure - -``` -app/ -├── Console/ # Artisan commands -├── Http/ # Controllers & Middleware -├── Models/ # Eloquent models -├── Mod/ # Your custom modules -└── Providers/ # Service providers - -config/ -└── core.php # Core framework configuration - -routes/ -├── web.php # Public web routes -├── api.php # REST API routes -└── console.php # Artisan commands -``` - -## Creating Modules +Run migrations: ```bash -# Create a new module with all features -php artisan make:mod Blog --all - -# Create module with specific features -php artisan make:mod Shop --web --api --admin +php artisan migrate ``` -Modules follow the event-driven pattern: +## Usage + +### Workspace Management ```php -current(); -use Core\Events\WebRoutesRegistering; -use Core\Events\ApiRoutesRegistering; -use Core\Events\AdminPanelBooting; - -class Boot -{ - public static array $listens = [ - WebRoutesRegistering::class => 'onWebRoutes', - ApiRoutesRegistering::class => 'onApiRoutes', - AdminPanelBooting::class => 'onAdminPanel', - ]; - - public function onWebRoutes(WebRoutesRegistering $event): void - { - $event->routes(fn() => require __DIR__.'/Routes/web.php'); - $event->views('blog', __DIR__.'/Views'); - } -} +// Create a new workspace +$workspace = app(WorkspaceService::class)->create([ + 'name' => 'My Workspace', + 'owner_id' => $user->id, +]); ``` -## Core Packages +### Entitlements -| Package | Description | -|---------|-------------| -| `host-uk/core` | Core framework components | -| `host-uk/core-admin` | Admin panel & Livewire modals | -| `host-uk/core-api` | REST API with scopes & webhooks | -| `host-uk/core-mcp` | Model Context Protocol tools | +```php +use Core\Mod\Tenant\Services\EntitlementService; -## Flux Pro (Optional) +$entitlements = app(EntitlementService::class); -This template uses the free Flux UI components. If you have a Flux Pro license: +// Check if workspace has access to a feature +if ($entitlements->hasAccess($workspace, 'premium_feature')) { + // Feature is enabled +} + +// Check usage limits +$usage = $entitlements->getUsage($workspace, 'api_calls'); +``` + +### Middleware + +The module provides middleware for workspace-based access control: + +```php +// In your routes +Route::middleware('workspace.permission:manage-users')->group(function () { + // Routes requiring manage-users permission +}); +``` + +## Models + +| Model | Description | +|-------|-------------| +| `User` | Application users | +| `Workspace` | Tenant workspace boundaries | +| `WorkspaceMember` | Workspace membership with roles | +| `Entitlement` | Feature/package entitlements | +| `UsageRecord` | Usage tracking records | +| `Referral` | Referral tracking | + +## Events + +The module fires events for key actions: + +- `WorkspaceCreated` +- `WorkspaceMemberAdded` +- `WorkspaceMemberRemoved` +- `EntitlementChanged` +- `UsageAlertTriggered` + +## Artisan Commands ```bash -# Configure authentication -composer config http-basic.composer.fluxui.dev your-email your-license-key +# Refresh user statistics +php artisan tenant:refresh-user-stats -# Add the repository -composer config repositories.flux-pro composer https://composer.fluxui.dev +# Process scheduled account deletions +php artisan tenant:process-deletions -# Install Flux Pro -composer require livewire/flux-pro +# Check usage alerts +php artisan tenant:check-usage-alerts + +# Reset billing cycles +php artisan tenant:reset-billing-cycles ``` +## Configuration + +The module uses the Core PHP configuration system. Key settings can be configured per-workspace or system-wide. + ## Documentation - [Core PHP Framework](https://github.com/host-uk/core-php) - [Getting Started Guide](https://host-uk.github.io/core-php/guide/) -- [Architecture](https://host-uk.github.io/core-php/architecture/) ## License diff --git a/app/Http/Controllers/.gitkeep b/app/Http/Controllers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Mod/.gitkeep b/app/Mod/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Models/.gitkeep b/app/Models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php deleted file mode 100644 index 452e6b6..0000000 --- a/app/Providers/AppServiceProvider.php +++ /dev/null @@ -1,24 +0,0 @@ -handleCommand(new ArgvInput); - -exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php deleted file mode 100644 index 4687853..0000000 --- a/bootstrap/app.php +++ /dev/null @@ -1,26 +0,0 @@ -withProviders([ - // Core PHP Framework - \Core\LifecycleEventProvider::class, - \Core\Website\Boot::class, - \Core\Front\Boot::class, - \Core\Mod\Boot::class, - ]) - ->withRouting( - web: __DIR__.'/../routes/web.php', - api: __DIR__.'/../routes/api.php', - commands: __DIR__.'/../routes/console.php', - health: '/up', - ) - ->withMiddleware(function (Middleware $middleware) { - \Core\Front\Boot::middleware($middleware); - }) - ->withExceptions(function (Exceptions $exceptions) { - // - })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/bootstrap/cache/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php deleted file mode 100644 index 38b258d..0000000 --- a/bootstrap/providers.php +++ /dev/null @@ -1,5 +0,0 @@ - [ - app_path('Core'), - app_path('Mod'), - app_path('Website'), - ], - - 'services' => [ - 'cache_discovery' => env('CORE_CACHE_DISCOVERY', true), - ], - - 'cdn' => [ - 'enabled' => env('CDN_ENABLED', false), - 'driver' => env('CDN_DRIVER', 'bunny'), - ], -]; diff --git a/database/factories/.gitkeep b/database/factories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/database/migrations/.gitkeep b/database/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php deleted file mode 100644 index df6818f..0000000 --- a/database/seeders/DatabaseSeeder.php +++ /dev/null @@ -1,16 +0,0 @@ - - app + src diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index 49c0612..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/public/.htaccess b/public/.htaccess deleted file mode 100644 index 3aec5e2..0000000 --- a/public/.htaccess +++ /dev/null @@ -1,21 +0,0 @@ - - - Options -MultiViews -Indexes - - - RewriteEngine On - - # Handle Authorization Header - RewriteCond %{HTTP:Authorization} . - RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] - - # Redirect Trailing Slashes If Not A Folder... - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_URI} (.+)/$ - RewriteRule ^ %1 [L,R=301] - - # Send Requests To Front Controller... - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_FILENAME} !-f - RewriteRule ^ index.php [L] - diff --git a/public/index.php b/public/index.php deleted file mode 100644 index 947d989..0000000 --- a/public/index.php +++ /dev/null @@ -1,17 +0,0 @@ -handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index eb05362..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: diff --git a/resources/css/app.css b/resources/css/app.css deleted file mode 100644 index b5c61c9..0000000 --- a/resources/css/app.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/resources/js/app.js b/resources/js/app.js deleted file mode 100644 index e59d6a0..0000000 --- a/resources/js/app.js +++ /dev/null @@ -1 +0,0 @@ -import './bootstrap'; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js deleted file mode 100644 index 953d266..0000000 --- a/resources/js/bootstrap.js +++ /dev/null @@ -1,3 +0,0 @@ -import axios from 'axios'; -window.axios = axios; -window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index 88808ac..0000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - Core PHP Framework - - - -
-

Core PHP Framework

-

Laravel {{ Illuminate\Foundation\Application::VERSION }} | PHP {{ PHP_VERSION }}

- -
- - diff --git a/routes/api.php b/routes/api.php deleted file mode 100644 index 15fbf70..0000000 --- a/routes/api.php +++ /dev/null @@ -1,5 +0,0 @@ - + */ + public static array $listens = [ + AdminPanelBooting::class => 'onAdminPanel', + ApiRoutesRegistering::class => 'onApiRoutes', + WebRoutesRegistering::class => 'onWebRoutes', + ConsoleBooting::class => 'onConsole', + ]; + + public function register(): void + { + $this->app->singleton( + \Core\Mod\Tenant\Contracts\TwoFactorAuthenticationProvider::class, + \Core\Mod\Tenant\Services\TotpService::class + ); + + $this->app->singleton( + \Core\Mod\Tenant\Services\EntitlementService::class, + \Core\Mod\Tenant\Services\EntitlementService::class + ); + + $this->app->singleton( + \Core\Mod\Tenant\Services\WorkspaceManager::class, + \Core\Mod\Tenant\Services\WorkspaceManager::class + ); + + $this->app->singleton( + \Core\Mod\Tenant\Services\UserStatsService::class, + \Core\Mod\Tenant\Services\UserStatsService::class + ); + + $this->app->singleton( + \Core\Mod\Tenant\Services\WorkspaceService::class, + \Core\Mod\Tenant\Services\WorkspaceService::class + ); + + $this->app->singleton( + \Core\Mod\Tenant\Services\WorkspaceCacheManager::class, + \Core\Mod\Tenant\Services\WorkspaceCacheManager::class + ); + + $this->app->singleton( + \Core\Mod\Tenant\Services\UsageAlertService::class, + \Core\Mod\Tenant\Services\UsageAlertService::class + ); + + $this->app->singleton( + \Core\Mod\Tenant\Services\EntitlementWebhookService::class, + \Core\Mod\Tenant\Services\EntitlementWebhookService::class + ); + + $this->app->singleton( + \Core\Mod\Tenant\Services\WorkspaceTeamService::class, + \Core\Mod\Tenant\Services\WorkspaceTeamService::class + ); + + $this->registerBackwardCompatAliases(); + } + + protected function registerBackwardCompatAliases(): void + { + if (! class_exists(\App\Services\WorkspaceManager::class)) { + class_alias( + \Core\Mod\Tenant\Services\WorkspaceManager::class, + \App\Services\WorkspaceManager::class + ); + } + + if (! class_exists(\App\Services\UserStatsService::class)) { + class_alias( + \Core\Mod\Tenant\Services\UserStatsService::class, + \App\Services\UserStatsService::class + ); + } + + if (! class_exists(\App\Services\WorkspaceService::class)) { + class_alias( + \Core\Mod\Tenant\Services\WorkspaceService::class, + \App\Services\WorkspaceService::class + ); + } + + if (! class_exists(\App\Services\WorkspaceCacheManager::class)) { + class_alias( + \Core\Mod\Tenant\Services\WorkspaceCacheManager::class, + \App\Services\WorkspaceCacheManager::class + ); + } + } + + public function boot(): void + { + $this->loadMigrationsFrom(__DIR__.'/Migrations'); + $this->loadTranslationsFrom(__DIR__.'/Lang/en_GB', 'tenant'); + } + + // ------------------------------------------------------------------------- + // Event-driven handlers + // ------------------------------------------------------------------------- + + public function onAdminPanel(AdminPanelBooting $event): void + { + $event->views($this->moduleName, __DIR__.'/View/Blade'); + + // Admin Livewire components + $event->livewire('tenant.admin.entitlement-webhook-manager', View\Modal\Admin\EntitlementWebhookManager::class); + } + + public function onApiRoutes(ApiRoutesRegistering $event): void + { + if (file_exists(__DIR__.'/Routes/api.php')) { + $event->routes(fn () => Route::middleware('api')->group(__DIR__.'/Routes/api.php')); + } + } + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views($this->moduleName, __DIR__.'/View/Blade'); + + if (file_exists(__DIR__.'/Routes/web.php')) { + $event->routes(fn () => Route::middleware('web')->group(__DIR__.'/Routes/web.php')); + } + + // Account management + $event->livewire('tenant.account.cancel-deletion', View\Modal\Web\CancelDeletion::class); + $event->livewire('tenant.account.confirm-deletion', View\Modal\Web\ConfirmDeletion::class); + + // Workspace + $event->livewire('tenant.workspace.home', View\Modal\Web\WorkspaceHome::class); + } + + public function onConsole(ConsoleBooting $event): void + { + $event->middleware('admin.domain', Middleware\RequireAdminDomain::class); + $event->middleware('workspace.permission', Middleware\CheckWorkspacePermission::class); + + // Artisan commands + $event->command(Console\Commands\RefreshUserStats::class); + $event->command(Console\Commands\ProcessAccountDeletions::class); + $event->command(Console\Commands\CheckUsageAlerts::class); + $event->command(Console\Commands\ResetBillingCycles::class); + } +} diff --git a/src/Concerns/BelongsToNamespace.php b/src/Concerns/BelongsToNamespace.php new file mode 100644 index 0000000..ab25e5b --- /dev/null +++ b/src/Concerns/BelongsToNamespace.php @@ -0,0 +1,247 @@ +where('is_active', true)->get(); + */ +trait BelongsToNamespace +{ + /** + * Boot the trait - sets up auto-assignment of namespace_id and cache invalidation. + */ + protected static function bootBelongsToNamespace(): void + { + // Auto-assign namespace_id when creating a model without one + static::creating(function ($model) { + if (empty($model->namespace_id)) { + $namespace = static::getCurrentNamespace(); + if ($namespace) { + $model->namespace_id = $namespace->id; + } + } + }); + + static::saved(function ($model) { + if ($model->namespace_id) { + static::clearNamespaceCache($model->namespace_id); + } + }); + + static::deleted(function ($model) { + if ($model->namespace_id) { + static::clearNamespaceCache($model->namespace_id); + } + }); + } + + /** + * Get the namespace this model belongs to. + */ + public function namespace(): BelongsTo + { + return $this->belongsTo(Namespace_::class, 'namespace_id'); + } + + /** + * Scope query to the current namespace. + */ + public function scopeOwnedByCurrentNamespace(Builder $query): Builder + { + $namespace = static::getCurrentNamespace(); + + if (! $namespace) { + return $query->whereRaw('1 = 0'); // Return empty result + } + + return $query->where('namespace_id', $namespace->id); + } + + /** + * Scope query to a specific namespace. + */ + public function scopeForNamespace(Builder $query, Namespace_|int $namespace): Builder + { + $namespaceId = $namespace instanceof Namespace_ ? $namespace->id : $namespace; + + return $query->where('namespace_id', $namespaceId); + } + + /** + * Scope query to all namespaces accessible by the current user. + */ + public function scopeAccessibleByCurrentUser(Builder $query): Builder + { + $user = auth()->user(); + + if (! $user || ! $user instanceof User) { + return $query->whereRaw('1 = 0'); // Return empty result + } + + $namespaceIds = Namespace_::accessibleBy($user)->pluck('id'); + + return $query->whereIn('namespace_id', $namespaceIds); + } + + /** + * Get all models owned by the current namespace, cached. + * + * @param int $ttl Cache TTL in seconds (default 5 minutes) + */ + public static function ownedByCurrentNamespaceCached(int $ttl = 300): Collection + { + $namespace = static::getCurrentNamespace(); + + if (! $namespace) { + return collect(); + } + + return Cache::remember( + static::namespaceCacheKey($namespace->id), + $ttl, + fn () => static::ownedByCurrentNamespace()->get() + ); + } + + /** + * Get all models for a specific namespace, cached. + * + * @param int $ttl Cache TTL in seconds (default 5 minutes) + */ + public static function forNamespaceCached(Namespace_|int $namespace, int $ttl = 300): Collection + { + $namespaceId = $namespace instanceof Namespace_ ? $namespace->id : $namespace; + + return Cache::remember( + static::namespaceCacheKey($namespaceId), + $ttl, + fn () => static::forNamespace($namespaceId)->get() + ); + } + + /** + * Get the cache key for a namespace's model collection. + */ + protected static function namespaceCacheKey(int $namespaceId): string + { + $modelClass = class_basename(static::class); + + return "namespace.{$namespaceId}.{$modelClass}"; + } + + /** + * Clear the cache for a namespace's model collection. + */ + public static function clearNamespaceCache(int $namespaceId): void + { + Cache::forget(static::namespaceCacheKey($namespaceId)); + } + + /** + * Clear cache for all namespaces accessible to current user. + */ + public static function clearAllNamespaceCache(): void + { + $user = auth()->user(); + + if ($user && $user instanceof User) { + $namespaces = Namespace_::accessibleBy($user)->get(); + foreach ($namespaces as $namespace) { + static::clearNamespaceCache($namespace->id); + } + } + } + + /** + * Get the current namespace from session/request. + */ + protected static function getCurrentNamespace(): ?Namespace_ + { + // Try to get from request attributes (set by middleware) + if (request()->attributes->has('current_namespace')) { + return request()->attributes->get('current_namespace'); + } + + // Try to get from session + $namespaceUuid = session('current_namespace_uuid'); + if ($namespaceUuid) { + $namespace = Namespace_::where('uuid', $namespaceUuid)->first(); + if ($namespace) { + return $namespace; + } + } + + // Fall back to user's default namespace + $user = auth()->user(); + if ($user && method_exists($user, 'defaultNamespace')) { + return $user->defaultNamespace(); + } + + return null; + } + + /** + * Check if this model belongs to the given namespace. + */ + public function belongsToNamespace(Namespace_|int $namespace): bool + { + $namespaceId = $namespace instanceof Namespace_ ? $namespace->id : $namespace; + + return $this->namespace_id === $namespaceId; + } + + /** + * Check if this model belongs to the current namespace. + */ + public function belongsToCurrentNamespace(): bool + { + $namespace = static::getCurrentNamespace(); + + if (! $namespace) { + return false; + } + + return $this->belongsToNamespace($namespace); + } + + /** + * Check if the current user can access this model. + */ + public function isAccessibleByCurrentUser(): bool + { + $user = auth()->user(); + + if (! $user || ! $user instanceof User) { + return false; + } + + if (! $this->namespace) { + return false; + } + + return $this->namespace->isAccessibleBy($user); + } +} diff --git a/src/Concerns/BelongsToWorkspace.php b/src/Concerns/BelongsToWorkspace.php new file mode 100644 index 0000000..61d45ee --- /dev/null +++ b/src/Concerns/BelongsToWorkspace.php @@ -0,0 +1,349 @@ +where('status', 'active')->get(); + * + * To opt out of strict mode (not recommended): + * class LegacyModel extends Model { + * use BelongsToWorkspace; + * protected bool $workspaceContextRequired = false; + * } + * + * For custom caching beyond the default ownedByCurrentWorkspace, also use HasWorkspaceCache: + * class Account extends Model { + * use BelongsToWorkspace, HasWorkspaceCache; + * + * public static function getActiveAccounts(): Collection + * { + * return static::rememberForWorkspace( + * 'active_accounts', + * 300, + * fn() => static::ownedByCurrentWorkspace()->where('status', 'active')->get() + * ); + * } + * } + */ +trait BelongsToWorkspace +{ + /** + * Boot the trait - sets up auto-assignment of workspace_id and cache invalidation. + * + * SECURITY: Throws MissingWorkspaceContextException when creating without workspace context, + * unless the model has opted out with $workspaceContextRequired = false. + */ + protected static function bootBelongsToWorkspace(): void + { + // Auto-assign workspace_id when creating a model without one + static::creating(function ($model) { + if (empty($model->workspace_id)) { + $workspace = static::getCurrentWorkspace(); + + if ($workspace) { + $model->workspace_id = $workspace->id; + + return; + } + + // No workspace context - check if we should enforce + if ($model->requiresWorkspaceContext()) { + throw MissingWorkspaceContextException::forCreate( + class_basename($model) + ); + } + } + }); + + // Clear cache on saved event (create/update) + static::saved(function ($model) { + if ($model->workspace_id) { + static::clearWorkspaceCache($model->workspace_id); + } + }); + + // Clear cache on deleted event + static::deleted(function ($model) { + if ($model->workspace_id) { + static::clearWorkspaceCache($model->workspace_id); + } + }); + } + + /** + * Determine if this model requires workspace context. + * + * Models can opt out by setting $workspaceContextRequired = false, + * but this is not recommended for security reasons. + */ + public function requiresWorkspaceContext(): bool + { + // Check model-level setting + if (property_exists($this, 'workspaceContextRequired')) { + return $this->workspaceContextRequired; + } + + // Check if global strict mode is disabled + if (! WorkspaceScope::isStrictModeEnabled()) { + return false; + } + + // Check if running from console (CLI commands may need to work without context) + if (app()->runningInConsole() && ! app()->runningUnitTests()) { + return false; + } + + // Default: require workspace context for security + return true; + } + + /** + * Get the workspace this model belongs to. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * Scope query to the current user's default workspace. + * + * SECURITY: Throws MissingWorkspaceContextException when no workspace context + * is available and strict mode is enabled. + * + * @throws MissingWorkspaceContextException When workspace context is missing in strict mode + */ + public function scopeOwnedByCurrentWorkspace(Builder $query): Builder + { + $workspace = static::getCurrentWorkspace(); + + if ($workspace) { + return $query->where('workspace_id', $workspace->id); + } + + // No workspace context - check if we should enforce strict mode + if ($this->requiresWorkspaceContext()) { + throw MissingWorkspaceContextException::forScope( + class_basename($this) + ); + } + + // Non-strict mode: return empty result set (fail safe) + return $query->whereRaw('1 = 0'); + } + + /** + * Scope query to a specific workspace. + */ + public function scopeForWorkspace(Builder $query, Workspace|int $workspace): Builder + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return $query->where('workspace_id', $workspaceId); + } + + /** + * Get all models owned by the current workspace, cached. + * + * Uses the WorkspaceCacheManager for caching, which supports both + * tagged cache stores (Redis, Memcached) and non-tagged stores. + * + * SECURITY: Throws MissingWorkspaceContextException when no workspace context + * is available and strict mode is enabled. + * + * @param int|null $ttl Cache TTL in seconds (null = use config default) + * + * @throws MissingWorkspaceContextException When workspace context is missing in strict mode + */ + public static function ownedByCurrentWorkspaceCached(?int $ttl = null): Collection + { + $workspace = static::getCurrentWorkspace(); + + if ($workspace) { + return static::getWorkspaceCacheManager()->rememberModel( + $workspace, + static::class, + static::getDefaultCacheKey(), + $ttl, + fn () => static::ownedByCurrentWorkspace()->get() + ); + } + + // No workspace context - check if we should enforce strict mode + $instance = new static; + if ($instance->requiresWorkspaceContext()) { + throw MissingWorkspaceContextException::forScope( + class_basename(static::class) + ); + } + + // Non-strict mode: return empty collection (fail safe) + return collect(); + } + + /** + * Get all models for a specific workspace, cached. + * + * @param int|null $ttl Cache TTL in seconds (null = use config default) + */ + public static function forWorkspaceCached(Workspace|int $workspace, ?int $ttl = null): Collection + { + return static::getWorkspaceCacheManager()->rememberModel( + $workspace, + static::class, + static::getDefaultCacheKey(), + $ttl, + fn () => static::forWorkspace($workspace)->get() + ); + } + + /** + * Get the cache key for a workspace's model collection. + * + * This generates the full cache key including the workspace-scoped prefix. + */ + public static function workspaceCacheKey(int $workspaceId): string + { + return static::getWorkspaceCacheManager()->key( + $workspaceId, + static::getDefaultCacheKey() + ); + } + + /** + * Get the default cache key suffix for this model. + * + * Override this in your model to customise the cache key. + */ + protected static function getDefaultCacheKey(): string + { + return class_basename(static::class).'.all'; + } + + /** + * Clear the cache for a workspace's model collection. + * + * This clears the default cached collection. If using HasWorkspaceCache + * for custom cached queries, you may need to clear those separately. + */ + public static function clearWorkspaceCache(int $workspaceId): void + { + static::getWorkspaceCacheManager()->forget( + $workspaceId, + static::getDefaultCacheKey() + ); + } + + /** + * Clear cache for all workspaces this model exists in. + * + * For tagged cache stores (Redis), this flushes all cache for this model. + * For non-tagged stores, this clears cache for workspaces the current user has access to. + */ + public static function clearAllWorkspaceCaches(): void + { + $manager = static::getWorkspaceCacheManager(); + + // If tags are supported, we can flush all cache for this model efficiently + if ($manager->supportsTags()) { + $manager->flushModel(static::class); + + return; + } + + // For non-tagged stores, clear for all workspaces the current user has access to + $user = auth()->user(); + + if ($user && method_exists($user, 'hostWorkspaces')) { + foreach ($user->hostWorkspaces as $workspace) { + static::clearWorkspaceCache($workspace->id); + } + } + } + + /** + * Get the current user's default workspace. + * + * First checks request attributes (set by middleware), then falls back + * to the authenticated user's default workspace. + */ + protected static function getCurrentWorkspace(): ?Workspace + { + // First try to get from request attributes (set by middleware) + if (request()->attributes->has('workspace_model')) { + return request()->attributes->get('workspace_model'); + } + + // Then try to get from authenticated user + $user = auth()->user(); + + if (! $user) { + return null; + } + + // Use the Host UK method if available + if (method_exists($user, 'defaultHostWorkspace')) { + return $user->defaultHostWorkspace(); + } + + return null; + } + + /** + * Check if this model belongs to the given workspace. + */ + public function belongsToWorkspace(Workspace|int $workspace): bool + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return $this->workspace_id === $workspaceId; + } + + /** + * Check if this model belongs to the current user's workspace. + */ + public function belongsToCurrentWorkspace(): bool + { + $workspace = static::getCurrentWorkspace(); + + if (! $workspace) { + return false; + } + + return $this->belongsToWorkspace($workspace); + } + + /** + * Get the workspace cache manager instance. + */ + protected static function getWorkspaceCacheManager(): WorkspaceCacheManager + { + return app(WorkspaceCacheManager::class); + } +} diff --git a/src/Concerns/HasWorkspaceCache.php b/src/Concerns/HasWorkspaceCache.php new file mode 100644 index 0000000..5ba50ba --- /dev/null +++ b/src/Concerns/HasWorkspaceCache.php @@ -0,0 +1,272 @@ + static::ownedByCurrentWorkspace() + * ->where('status', 'active') + * ->get() + * ); + * } + * } + */ +trait HasWorkspaceCache +{ + /** + * Remember a value for the current workspace. + * + * @template T + * + * @param string $key The cache key (will be prefixed with workspace context) + * @param int|null $ttl TTL in seconds (null = use default from config) + * @param Closure(): T $callback The callback to generate the value + * @return T + */ + public static function rememberForWorkspace(string $key, ?int $ttl, Closure $callback): mixed + { + $workspace = static::getCurrentWorkspaceForCache(); + + if (! $workspace) { + // No workspace context - execute callback directly without caching + return $callback(); + } + + // Include model name in key to avoid collisions + $modelKey = static::getCacheKeyForModel($key); + + return static::getWorkspaceCacheManager()->rememberModel( + $workspace, + static::class, + $modelKey, + $ttl, + $callback + ); + } + + /** + * Remember a value forever for the current workspace. + * + * @template T + * + * @param Closure(): T $callback + * @return T + */ + public static function rememberForWorkspaceForever(string $key, Closure $callback): mixed + { + $workspace = static::getCurrentWorkspaceForCache(); + + if (! $workspace) { + return $callback(); + } + + $modelKey = static::getCacheKeyForModel($key); + + return static::getWorkspaceCacheManager()->rememberForever( + $workspace, + $modelKey, + $callback + ); + } + + /** + * Remember a value for a specific workspace. + * + * @template T + * + * @param Closure(): T $callback + * @return T + */ + public static function rememberForSpecificWorkspace( + Workspace|int $workspace, + string $key, + ?int $ttl, + Closure $callback + ): mixed { + $modelKey = static::getCacheKeyForModel($key); + + return static::getWorkspaceCacheManager()->rememberModel( + $workspace, + static::class, + $modelKey, + $ttl, + $callback + ); + } + + /** + * Store a value in cache for the current workspace. + */ + public static function putForWorkspace(string $key, mixed $value, ?int $ttl = null): bool + { + $workspace = static::getCurrentWorkspaceForCache(); + + if (! $workspace) { + return false; + } + + $modelKey = static::getCacheKeyForModel($key); + + return static::getWorkspaceCacheManager()->put( + $workspace, + $modelKey, + $value, + $ttl + ); + } + + /** + * Get a cached value for the current workspace. + */ + public static function getFromWorkspaceCache(string $key, mixed $default = null): mixed + { + $workspace = static::getCurrentWorkspaceForCache(); + + if (! $workspace) { + return $default; + } + + $modelKey = static::getCacheKeyForModel($key); + + return static::getWorkspaceCacheManager()->get( + $workspace, + $modelKey, + $default + ); + } + + /** + * Check if a key exists in the workspace cache. + */ + public static function hasInWorkspaceCache(string $key): bool + { + $workspace = static::getCurrentWorkspaceForCache(); + + if (! $workspace) { + return false; + } + + $modelKey = static::getCacheKeyForModel($key); + + return static::getWorkspaceCacheManager()->has( + $workspace, + $modelKey + ); + } + + /** + * Forget a specific key from the current workspace cache. + */ + public static function forgetForWorkspace(string $key): bool + { + $workspace = static::getCurrentWorkspaceForCache(); + + if (! $workspace) { + return false; + } + + $modelKey = static::getCacheKeyForModel($key); + + return static::getWorkspaceCacheManager()->forget( + $workspace, + $modelKey + ); + } + + /** + * Forget a specific key from a specific workspace cache. + */ + public static function forgetForSpecificWorkspace(Workspace|int $workspace, string $key): bool + { + $modelKey = static::getCacheKeyForModel($key); + + return static::getWorkspaceCacheManager()->forget( + $workspace, + $modelKey + ); + } + + /** + * Clear all cache for the current workspace's model data. + */ + public static function clearWorkspaceCacheForModel(): bool + { + $workspace = static::getCurrentWorkspaceForCache(); + + if (! $workspace) { + return false; + } + + // Clear the default workspace cache key + return static::getWorkspaceCacheManager()->forget( + $workspace, + static::getCacheKeyForModel('all') + ); + } + + /** + * Clear all cache for this model across all workspaces. + * Only works with tagged cache stores (Redis, Memcached). + */ + public static function clearAllWorkspaceCacheForModel(): bool + { + return static::getWorkspaceCacheManager()->flushModel(static::class); + } + + /** + * Get the cache key prefix for this model. + */ + protected static function getCacheKeyForModel(string $key): string + { + return class_basename(static::class).'.'.$key; + } + + /** + * Get the current workspace for caching. + */ + protected static function getCurrentWorkspaceForCache(): ?Workspace + { + // First try to get from request attributes (set by middleware) + if (request()->attributes->has('workspace_model')) { + return request()->attributes->get('workspace_model'); + } + + // Then try to get from authenticated user + $user = auth()->user(); + + if ($user && method_exists($user, 'defaultHostWorkspace')) { + return $user->defaultHostWorkspace(); + } + + return null; + } + + /** + * Get the workspace cache manager instance. + */ + protected static function getWorkspaceCacheManager(): WorkspaceCacheManager + { + return app(WorkspaceCacheManager::class); + } +} diff --git a/src/Concerns/TwoFactorAuthenticatable.php b/src/Concerns/TwoFactorAuthenticatable.php new file mode 100644 index 0000000..f838870 --- /dev/null +++ b/src/Concerns/TwoFactorAuthenticatable.php @@ -0,0 +1,250 @@ +hasOne(UserTwoFactorAuth::class, 'user_id'); + } + + /** + * Check if two-factor authentication is enabled. + */ + public function hasTwoFactorAuthEnabled(): bool + { + if ($this->twoFactorAuth) { + return ! is_null($this->twoFactorAuth->secret_key) + && ! is_null($this->twoFactorAuth->confirmed_at); + } + + return false; + } + + /** + * Get the two-factor authentication secret key. + */ + public function twoFactorAuthSecretKey(): ?string + { + return $this->twoFactorAuth?->secret_key; + } + + /** + * Get the two-factor recovery codes. + */ + public function twoFactorRecoveryCodes(): array + { + return $this->twoFactorAuth?->recovery_codes?->toArray() ?? []; + } + + /** + * Replace a used recovery code with a new one. + */ + public function twoFactorReplaceRecoveryCode(string $code): void + { + if (! $this->twoFactorAuth) { + return; + } + + $codes = $this->twoFactorRecoveryCodes(); + $index = array_search($code, $codes); + + if ($index !== false) { + $codes[$index] = $this->generateRecoveryCode(); + $this->twoFactorAuth->update(['recovery_codes' => $codes]); + } + } + + /** + * Generate a QR code SVG for two-factor setup. + */ + public function twoFactorQrCodeSvg(): string + { + $secret = $this->twoFactorAuthSecretKey(); + if (! $secret) { + return ''; + } + + $url = $this->twoFactorQrCodeUrl(); + + return $this->getTotpService()->qrCodeSvg($url); + } + + /** + * Generate the TOTP URL for QR code. + */ + public function twoFactorQrCodeUrl(): string + { + return $this->getTotpService()->qrCodeUrl( + config('app.name'), + $this->email, + $this->twoFactorAuthSecretKey() + ); + } + + /** + * Verify a TOTP code. + */ + public function verifyTwoFactorCode(string $code): bool + { + $secret = $this->twoFactorAuthSecretKey(); + if (! $secret) { + return false; + } + + return $this->getTotpService()->verify($secret, $code); + } + + /** + * Generate a new two-factor secret. + */ + public function generateTwoFactorSecret(): string + { + return $this->getTotpService()->generateSecretKey(); + } + + /** + * Verify a recovery code. + * + * @return bool True if the recovery code was valid and used + */ + public function verifyRecoveryCode(string $code): bool + { + $codes = $this->twoFactorRecoveryCodes(); + $code = strtoupper(trim($code)); + + $index = array_search($code, $codes); + + if ($index !== false) { + $this->twoFactorReplaceRecoveryCode($code); + + return true; + } + + return false; + } + + /** + * Generate a random recovery code. + */ + protected function generateRecoveryCode(): string + { + return strtoupper(bin2hex(random_bytes(5))).'-'.strtoupper(bin2hex(random_bytes(5))); + } + + /** + * Generate a set of recovery codes. + * + * @param int $count Number of codes to generate + */ + public function generateRecoveryCodes(int $count = 8): array + { + $codes = []; + + for ($i = 0; $i < $count; $i++) { + $codes[] = $this->generateRecoveryCode(); + } + + return $codes; + } + + /** + * Enable two-factor authentication for this user. + * + * Creates the 2FA record with a new secret but does not confirm it yet. + * The user must verify a code before 2FA is fully enabled. + * + * @return string The secret key for QR code generation + */ + public function enableTwoFactorAuth(): string + { + $secret = $this->generateTwoFactorSecret(); + + $this->twoFactorAuth()->updateOrCreate( + ['user_id' => $this->id], + [ + 'secret_key' => $secret, + 'recovery_codes' => null, + 'confirmed_at' => null, + ] + ); + + $this->load('twoFactorAuth'); + + return $secret; + } + + /** + * Confirm two-factor authentication after verifying a code. + * + * @return array The recovery codes + */ + public function confirmTwoFactorAuth(): array + { + if (! $this->twoFactorAuth || ! $this->twoFactorAuth->secret_key) { + throw new \RuntimeException('Two-factor authentication has not been initialised.'); + } + + $recoveryCodes = $this->generateRecoveryCodes(); + + $this->twoFactorAuth->update([ + 'recovery_codes' => $recoveryCodes, + 'confirmed_at' => now(), + ]); + + return $recoveryCodes; + } + + /** + * Disable two-factor authentication for this user. + */ + public function disableTwoFactorAuth(): void + { + $this->twoFactorAuth?->delete(); + $this->unsetRelation('twoFactorAuth'); + } + + /** + * Regenerate recovery codes. + * + * @return array The new recovery codes + */ + public function regenerateTwoFactorRecoveryCodes(): array + { + if (! $this->hasTwoFactorAuthEnabled()) { + throw new \RuntimeException('Two-factor authentication is not enabled.'); + } + + $recoveryCodes = $this->generateRecoveryCodes(); + + $this->twoFactorAuth->update([ + 'recovery_codes' => $recoveryCodes, + ]); + + return $recoveryCodes; + } + + /** + * Get the TOTP service instance. + */ + protected function getTotpService(): TwoFactorAuthenticationProvider + { + return app(TwoFactorAuthenticationProvider::class); + } +} diff --git a/src/Console/Commands/CheckUsageAlerts.php b/src/Console/Commands/CheckUsageAlerts.php new file mode 100644 index 0000000..35cf2ca --- /dev/null +++ b/src/Console/Commands/CheckUsageAlerts.php @@ -0,0 +1,261 @@ +option('dry-run'); + $verbose = $this->option('verbose'); + + if ($dryRun) { + $this->info('DRY RUN: No notifications will be sent.'); + } + + if ($workspaceOption = $this->option('workspace')) { + return $this->checkSingleWorkspace($workspaceOption, $dryRun, $verbose); + } + + return $this->checkAllWorkspaces($dryRun, $verbose); + } + + /** + * Check a single workspace. + */ + protected function checkSingleWorkspace(string $identifier, bool $dryRun, bool $verbose): int + { + $workspace = is_numeric($identifier) + ? Workspace::find($identifier) + : Workspace::where('slug', $identifier)->first(); + + if (! $workspace) { + $this->error("Workspace not found: {$identifier}"); + + return self::FAILURE; + } + + $this->info("Checking workspace: {$workspace->name} ({$workspace->slug})"); + + if ($dryRun) { + $this->showUsageStatus($workspace); + + return self::SUCCESS; + } + + $result = $this->alertService->checkWorkspace($workspace); + + $this->info("Alerts sent: {$result['alerts_sent']}"); + $this->info("Alerts resolved: {$result['alerts_resolved']}"); + + if ($verbose && ! empty($result['details'])) { + $this->newLine(); + $this->table( + ['Feature', 'Usage %', 'Threshold', 'Action'], + collect($result['details'])->map(fn ($d) => [ + $d['feature'], + $d['percentage'] !== null ? round($d['percentage'], 1).'%' : 'N/A', + $d['threshold'] ? $d['threshold'].'%' : 'N/A', + $d['alert_sent'] ? 'Alert sent' : ($d['resolved'] ? 'Resolved' : 'No action'), + ])->toArray() + ); + } + + return self::SUCCESS; + } + + /** + * Check all workspaces. + */ + protected function checkAllWorkspaces(bool $dryRun, bool $verbose): int + { + $this->info('Checking all active workspaces for usage alerts...'); + + if ($dryRun) { + $this->showAllWorkspacesStatus($verbose); + + return self::SUCCESS; + } + + $result = $this->alertService->checkAllWorkspaces(); + + $this->newLine(); + $this->info("Workspaces checked: {$result['checked']}"); + $this->info("Alerts sent: {$result['alerts_sent']}"); + $this->info("Alerts resolved: {$result['alerts_resolved']}"); + + if ($result['alerts_sent'] > 0) { + $this->comment('Usage alert notifications have been queued for delivery.'); + } + + return self::SUCCESS; + } + + /** + * Show usage status for a single workspace (dry run). + */ + protected function showUsageStatus(Workspace $workspace): void + { + $status = $this->alertService->getUsageStatus($workspace); + + if ($status->isEmpty()) { + $this->info('No features with limits found.'); + + return; + } + + $this->newLine(); + $this->table( + ['Feature', 'Used', 'Limit', 'Usage %', 'Status', 'Active Alert'], + $status->map(fn ($s) => [ + $s['name'], + $s['used'] ?? 0, + $s['limit'] ?? 'N/A', + $s['percentage'] !== null ? round($s['percentage'], 1).'%' : 'N/A', + $this->getStatusLabel($s), + $s['active_alert'] ? $s['alert_threshold'].'% alert' : '-', + ])->toArray() + ); + + $approaching = $status->filter(fn ($s) => $s['near_limit'] || $s['at_limit']); + + if ($approaching->isNotEmpty()) { + $this->newLine(); + $this->warn("Features approaching limits: {$approaching->count()}"); + + foreach ($approaching as $item) { + $wouldSend = ! $item['active_alert'] || $item['alert_threshold'] < $this->getThresholdForPercentage($item['percentage']); + + if ($wouldSend) { + $this->line(" - {$item['name']}: Would send alert"); + } else { + $this->line(" - {$item['name']}: Alert already sent"); + } + } + } + } + + /** + * Show status for all workspaces (dry run). + */ + protected function showAllWorkspacesStatus(bool $verbose): void + { + $workspaces = Workspace::query() + ->active() + ->whereHas('workspacePackages', fn ($q) => $q->active()) + ->get(); + + $this->info("Found {$workspaces->count()} active workspaces with packages."); + + $alerts = []; + + foreach ($workspaces as $workspace) { + $status = $this->alertService->getUsageStatus($workspace); + $approaching = $status->filter(fn ($s) => $s['near_limit'] || $s['at_limit']); + + if ($approaching->isNotEmpty()) { + foreach ($approaching as $item) { + $alerts[] = [ + 'workspace' => $workspace->name, + 'feature' => $item['name'], + 'used' => $item['used'], + 'limit' => $item['limit'], + 'percentage' => round($item['percentage'], 1), + 'has_alert' => $item['active_alert'] !== null, + ]; + } + } + } + + if (empty($alerts)) { + $this->info('No workspaces are approaching limits.'); + + return; + } + + $this->newLine(); + $this->warn('Found '.count($alerts).' features approaching limits:'); + $this->newLine(); + + $this->table( + ['Workspace', 'Feature', 'Used', 'Limit', '%', 'Alert Sent?'], + collect($alerts)->map(fn ($a) => [ + $a['workspace'], + $a['feature'], + $a['used'], + $a['limit'], + $a['percentage'].'%', + $a['has_alert'] ? 'Yes' : 'No', + ])->toArray() + ); + } + + /** + * Get status label for display. + */ + protected function getStatusLabel(array $status): string + { + if ($status['at_limit']) { + return 'At Limit'; + } + + if ($status['percentage'] >= 90) { + return 'Critical'; + } + + if ($status['near_limit']) { + return 'Warning'; + } + + return 'OK'; + } + + /** + * Get threshold for a given percentage. + */ + protected function getThresholdForPercentage(?float $percentage): ?int + { + if ($percentage === null) { + return null; + } + + if ($percentage >= 100) { + return 100; + } + + if ($percentage >= 90) { + return 90; + } + + if ($percentage >= 80) { + return 80; + } + + return null; + } +} diff --git a/src/Console/Commands/ProcessAccountDeletions.php b/src/Console/Commands/ProcessAccountDeletions.php new file mode 100644 index 0000000..09c57a5 --- /dev/null +++ b/src/Console/Commands/ProcessAccountDeletions.php @@ -0,0 +1,82 @@ +with('user')->get(); + + if ($pendingDeletions->isEmpty()) { + $this->info('No pending account deletions to process.'); + + return self::SUCCESS; + } + + $this->info("Processing {$pendingDeletions->count()} account deletion(s)..."); + + $deleted = 0; + $failed = 0; + + foreach ($pendingDeletions as $request) { + try { + $user = $request->user; + + if (! $user) { + $this->warn("User not found for deletion request #{$request->id}"); + $request->complete(); + + continue; + } + + $this->line("Deleting account: {$user->email}"); + + DB::transaction(function () use ($request, $user) { + // Mark request as completed + $request->complete(); + + // Delete all workspaces owned by the user + if (method_exists($user, 'ownedWorkspaces')) { + $user->ownedWorkspaces()->each(function ($workspace) { + $workspace->delete(); + }); + } + + // Hard delete user account + $user->forceDelete(); + }); + + Log::info('Account deleted via scheduled task', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'deletion_request_id' => $request->id, + ]); + + $deleted++; + } catch (\Exception $e) { + $this->error("Failed to delete account for request #{$request->id}: {$e->getMessage()}"); + Log::error('Failed to process account deletion', [ + 'deletion_request_id' => $request->id, + 'error' => $e->getMessage(), + ]); + $failed++; + } + } + + $this->info("Completed: {$deleted} deleted, {$failed} failed."); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/src/Console/Commands/RefreshUserStats.php b/src/Console/Commands/RefreshUserStats.php new file mode 100644 index 0000000..2e69729 --- /dev/null +++ b/src/Console/Commands/RefreshUserStats.php @@ -0,0 +1,56 @@ +option('user')) { + $this->refreshUser($userId); + + return Command::SUCCESS; + } + + // Refresh all users with stale stats (> 1 hour old) + $staleUsers = User::where(function ($query) { + $query->whereNull('stats_computed_at') + ->orWhere('stats_computed_at', '<', now()->subHour()); + })->pluck('id'); + + $this->info("Queuing stats refresh for {$staleUsers->count()} users..."); + + foreach ($staleUsers as $userId) { + ComputeUserStats::dispatch($userId)->onQueue('stats'); + } + + $this->info('Done! Stats will be computed in background.'); + + return Command::SUCCESS; + } + + protected function refreshUser(int $userId): void + { + $user = User::find($userId); + + if (! $user) { + $this->error("User {$userId} not found."); + + return; + } + + $this->info("Computing stats for user: {$user->name}..."); + ComputeUserStats::dispatchSync($userId); + $this->info('Done!'); + } +} diff --git a/src/Console/Commands/ResetBillingCycles.php b/src/Console/Commands/ResetBillingCycles.php new file mode 100644 index 0000000..4c64106 --- /dev/null +++ b/src/Console/Commands/ResetBillingCycles.php @@ -0,0 +1,411 @@ +option('dry-run'); + $verbose = $this->option('verbose'); + + if ($dryRun) { + $this->info('DRY RUN: No changes will be made.'); + } + + $this->info('Starting billing cycle reset process...'); + $this->newLine(); + + if ($workspaceOption = $this->option('workspace')) { + return $this->processSingleWorkspace($workspaceOption, $dryRun, $verbose); + } + + return $this->processAllWorkspaces($dryRun, $verbose); + } + + /** + * Process a single workspace. + */ + protected function processSingleWorkspace(string $identifier, bool $dryRun, bool $verbose): int + { + $workspace = is_numeric($identifier) + ? Workspace::find($identifier) + : Workspace::where('slug', $identifier)->first(); + + if (! $workspace) { + $this->error("Workspace not found: {$identifier}"); + + return self::FAILURE; + } + + $this->info("Processing workspace: {$workspace->name} ({$workspace->slug})"); + + $result = $this->processWorkspace($workspace, $dryRun, $verbose); + + $this->outputSummary(); + + return $result ? self::SUCCESS : self::FAILURE; + } + + /** + * Process all workspaces. + */ + protected function processAllWorkspaces(bool $dryRun, bool $verbose): int + { + // Get workspaces with active packages + $workspaces = Workspace::query() + ->active() + ->whereHas('workspacePackages', fn ($q) => $q->active()) + ->get(); + + $this->info("Found {$workspaces->count()} active workspaces with packages."); + $this->newLine(); + + $bar = $this->output->createProgressBar($workspaces->count()); + $bar->start(); + + foreach ($workspaces as $workspace) { + try { + $this->processWorkspace($workspace, $dryRun, $verbose); + $this->workspacesProcessed++; + } catch (\Exception $e) { + $this->newLine(); + $this->error("Error processing workspace {$workspace->slug}: {$e->getMessage()}"); + + Log::error('Billing cycle reset failed for workspace', [ + 'workspace_id' => $workspace->id, + 'workspace_slug' => $workspace->slug, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + + $this->outputSummary(); + + return self::SUCCESS; + } + + /** + * Process a single workspace's billing cycle. + */ + protected function processWorkspace(Workspace $workspace, bool $dryRun, bool $verbose): bool + { + // Get the primary (base) package to determine billing cycle + $primaryPackage = $workspace->workspacePackages() + ->whereHas('package', fn ($q) => $q->where('is_base_package', true)) + ->active() + ->first(); + + if (! $primaryPackage) { + if ($verbose) { + $this->line(" Skipping {$workspace->name}: No active base package"); + } + + return true; + } + + $cycleStart = $primaryPackage->getCurrentCycleStart(); + $cycleEnd = $primaryPackage->getCurrentCycleEnd(); + $previousCycleEnd = $cycleStart; + + // Determine if we're at a billing cycle boundary (within 24 hours of cycle start) + $isAtCycleStart = now()->diffInHours($cycleStart) < 24 && now()->gte($cycleStart); + + if ($verbose) { + $this->newLine(); + $this->line(" Workspace: {$workspace->name}"); + $this->line(" Cycle: {$cycleStart->format('Y-m-d')} to {$cycleEnd->format('Y-m-d')}"); + $this->line(' At cycle start: '.($isAtCycleStart ? 'Yes' : 'No')); + } + + // 1. Expire cycle-bound boosts from previous cycle + $expiredBoosts = $this->expireCycleBoundBoosts($workspace, $previousCycleEnd, $dryRun, $verbose); + + // 2. Reset usage counters at cycle start + if ($isAtCycleStart) { + $this->resetUsageCounters($workspace, $cycleStart, $dryRun, $verbose); + } + + // 3. Expire time-based boosts that have passed their expiry + $this->expireTimedBoosts($workspace, $dryRun, $verbose); + + // 4. Send notifications for expired boosts + if (! $dryRun && $expiredBoosts->isNotEmpty()) { + $this->sendBoostExpiryNotifications($workspace, $expiredBoosts, $verbose); + } + + return true; + } + + /** + * Expire cycle-bound boosts that should have ended in the previous cycle. + */ + protected function expireCycleBoundBoosts(Workspace $workspace, Carbon $cycleEnd, bool $dryRun, bool $verbose): Collection + { + $boosts = $workspace->boosts() + ->where('duration_type', Boost::DURATION_CYCLE_BOUND) + ->where('status', Boost::STATUS_ACTIVE) + ->where(function ($q) { + // Either no explicit expiry (cycle-bound) or expiry has passed + $q->whereNull('expires_at') + ->orWhere('expires_at', '<=', now()); + }) + ->get(); + + if ($boosts->isEmpty()) { + return collect(); + } + + if ($verbose) { + $this->line(" Found {$boosts->count()} cycle-bound boosts to expire"); + } + + if ($dryRun) { + foreach ($boosts as $boost) { + $this->line(" [DRY RUN] Would expire boost: {$boost->feature_code} (ID: {$boost->id})"); + } + + return $boosts; + } + + DB::transaction(function () use ($workspace, $boosts) { + foreach ($boosts as $boost) { + $boost->expire(); + + EntitlementLog::logBoostAction( + $workspace, + EntitlementLog::ACTION_BOOST_EXPIRED, + $boost, + source: EntitlementLog::SOURCE_SYSTEM, + metadata: [ + 'reason' => 'Billing cycle ended', + 'expired_at' => now()->toIso8601String(), + ] + ); + + $this->boostsExpired++; + } + }); + + // Invalidate entitlement cache + $this->entitlementService->invalidateCache($workspace); + + Log::info('Billing cycle: Expired cycle-bound boosts', [ + 'workspace_id' => $workspace->id, + 'workspace_slug' => $workspace->slug, + 'boosts_expired' => $boosts->count(), + 'boost_ids' => $boosts->pluck('id')->toArray(), + ]); + + return $boosts; + } + + /** + * Expire boosts with explicit time-based expiry that has passed. + */ + protected function expireTimedBoosts(Workspace $workspace, bool $dryRun, bool $verbose): void + { + $boosts = $workspace->boosts() + ->where('duration_type', Boost::DURATION_DURATION) + ->where('status', Boost::STATUS_ACTIVE) + ->where('expires_at', '<=', now()) + ->get(); + + if ($boosts->isEmpty()) { + return; + } + + if ($verbose) { + $this->line(" Found {$boosts->count()} timed boosts to expire"); + } + + if ($dryRun) { + foreach ($boosts as $boost) { + $this->line(" [DRY RUN] Would expire timed boost: {$boost->feature_code} (ID: {$boost->id})"); + } + + return; + } + + DB::transaction(function () use ($workspace, $boosts) { + foreach ($boosts as $boost) { + $boost->expire(); + + EntitlementLog::logBoostAction( + $workspace, + EntitlementLog::ACTION_BOOST_EXPIRED, + $boost, + source: EntitlementLog::SOURCE_SYSTEM, + metadata: [ + 'reason' => 'Duration expired', + 'expires_at' => $boost->expires_at->toIso8601String(), + 'expired_at' => now()->toIso8601String(), + ] + ); + + $this->boostsExpired++; + } + }); + + $this->entitlementService->invalidateCache($workspace); + } + + /** + * Reset usage counters for cycle-based features. + * + * Note: We don't actually delete usage records - instead, the EntitlementService + * calculates usage based on the current cycle start date. This method logs the + * cycle reset for audit purposes. + */ + protected function resetUsageCounters(Workspace $workspace, Carbon $cycleStart, bool $dryRun, bool $verbose): void + { + // Get count of usage records from previous cycle + $previousUsage = UsageRecord::where('workspace_id', $workspace->id) + ->where('recorded_at', '<', $cycleStart) + ->count(); + + if ($previousUsage === 0) { + return; + } + + if ($verbose) { + $this->line(" Cycle reset: {$previousUsage} usage records now in previous cycle"); + } + + if ($dryRun) { + $this->line(' [DRY RUN] Would log cycle reset for workspace'); + + return; + } + + // Log the cycle reset for audit trail + EntitlementLog::create([ + 'workspace_id' => $workspace->id, + 'action' => EntitlementLog::ACTION_CYCLE_RESET, + 'entity_type' => 'workspace', + 'entity_id' => $workspace->id, + 'source' => EntitlementLog::SOURCE_SYSTEM, + 'metadata' => [ + 'cycle_start' => $cycleStart->toIso8601String(), + 'previous_cycle_records' => $previousUsage, + 'reset_at' => now()->toIso8601String(), + ], + ]); + + $this->usageCountersReset++; + + // Invalidate usage cache so new calculations use current cycle + $this->entitlementService->invalidateCache($workspace); + + Log::info('Billing cycle: Reset usage counters', [ + 'workspace_id' => $workspace->id, + 'workspace_slug' => $workspace->slug, + 'cycle_start' => $cycleStart->toIso8601String(), + 'previous_cycle_records' => $previousUsage, + ]); + } + + /** + * Send notifications to workspace owner about expired boosts. + */ + protected function sendBoostExpiryNotifications(Workspace $workspace, Collection $expiredBoosts, bool $verbose): void + { + $owner = $workspace->owner(); + + if (! $owner) { + if ($verbose) { + $this->line(' No owner found for notification'); + } + + return; + } + + try { + $owner->notify(new BoostExpiredNotification($workspace, $expiredBoosts)); + $this->notificationsSent++; + + if ($verbose) { + $this->line(" Sent boost expiry notification to: {$owner->email}"); + } + } catch (\Exception $e) { + Log::error('Failed to send boost expiry notification', [ + 'workspace_id' => $workspace->id, + 'user_id' => $owner->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Output summary statistics. + */ + protected function outputSummary(): void + { + $this->info('Billing cycle reset completed.'); + $this->newLine(); + + $this->table( + ['Metric', 'Count'], + [ + ['Workspaces processed', $this->workspacesProcessed], + ['Boosts expired', $this->boostsExpired], + ['Usage cycles reset', $this->usageCountersReset], + ['Notifications sent', $this->notificationsSent], + ] + ); + + if ($this->boostsExpired > 0) { + $this->comment('Boost expiry notifications have been queued for delivery.'); + } + } +} diff --git a/src/Contracts/EntitlementWebhookEvent.php b/src/Contracts/EntitlementWebhookEvent.php new file mode 100644 index 0000000..569a070 --- /dev/null +++ b/src/Contracts/EntitlementWebhookEvent.php @@ -0,0 +1,37 @@ + + */ + public function payload(): array; + + /** + * Get a human-readable message for this event. + */ + public function message(): string; +} diff --git a/src/Contracts/TwoFactorAuthenticationProvider.php b/src/Contracts/TwoFactorAuthenticationProvider.php new file mode 100644 index 0000000..eb5230b --- /dev/null +++ b/src/Contracts/TwoFactorAuthenticationProvider.php @@ -0,0 +1,36 @@ +resolveWorkspace($request); + + $webhooks = EntitlementWebhook::query() + ->forWorkspace($workspace) + ->withCount('deliveries') + ->latest() + ->paginate($request->integer('per_page', 25)); + + return response()->json($webhooks); + } + + /** + * Create a new webhook. + */ + public function store(Request $request): JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'url' => ['required', 'url', 'max:2048'], + 'events' => ['required', 'array', 'min:1'], + 'events.*' => ['string', Rule::in(EntitlementWebhook::EVENTS)], + 'secret' => ['nullable', 'string', 'min:32'], + 'metadata' => ['nullable', 'array'], + ]); + + $webhook = $this->webhookService->register( + workspace: $workspace, + name: $validated['name'], + url: $validated['url'], + events: $validated['events'], + secret: $validated['secret'] ?? null, + metadata: $validated['metadata'] ?? [] + ); + + return response()->json([ + 'message' => __('Webhook created successfully'), + 'webhook' => $webhook, + 'secret' => $webhook->secret, // Return secret on creation only + ], 201); + } + + /** + * Get a specific webhook. + */ + public function show(Request $request, EntitlementWebhook $webhook): JsonResponse + { + $this->authorizeWebhook($request, $webhook); + + $webhook->loadCount('deliveries'); + $webhook->load(['deliveries' => fn ($q) => $q->latest('created_at')->limit(10)]); + + return response()->json([ + 'webhook' => $webhook, + 'available_events' => $this->webhookService->getAvailableEvents(), + ]); + } + + /** + * Update a webhook. + */ + public function update(Request $request, EntitlementWebhook $webhook): JsonResponse + { + $this->authorizeWebhook($request, $webhook); + + $validated = $request->validate([ + 'name' => ['sometimes', 'string', 'max:255'], + 'url' => ['sometimes', 'url', 'max:2048'], + 'events' => ['sometimes', 'array', 'min:1'], + 'events.*' => ['string', Rule::in(EntitlementWebhook::EVENTS)], + 'is_active' => ['sometimes', 'boolean'], + 'max_attempts' => ['sometimes', 'integer', 'min:1', 'max:10'], + 'metadata' => ['sometimes', 'array'], + ]); + + $webhook = $this->webhookService->update($webhook, $validated); + + return response()->json([ + 'message' => __('Webhook updated successfully'), + 'webhook' => $webhook, + ]); + } + + /** + * Delete a webhook. + */ + public function destroy(Request $request, EntitlementWebhook $webhook): JsonResponse + { + $this->authorizeWebhook($request, $webhook); + + $this->webhookService->unregister($webhook); + + return response()->json([ + 'message' => __('Webhook deleted successfully'), + ]); + } + + /** + * Regenerate webhook secret. + */ + public function regenerateSecret(Request $request, EntitlementWebhook $webhook): JsonResponse + { + $this->authorizeWebhook($request, $webhook); + + $secret = $webhook->regenerateSecret(); + + return response()->json([ + 'message' => __('Secret regenerated successfully'), + 'secret' => $secret, + ]); + } + + /** + * Send a test webhook. + */ + public function test(Request $request, EntitlementWebhook $webhook): JsonResponse + { + $this->authorizeWebhook($request, $webhook); + + $delivery = $this->webhookService->testWebhook($webhook); + + return response()->json([ + 'message' => $delivery->isSucceeded() + ? __('Test webhook sent successfully') + : __('Test webhook failed'), + 'delivery' => $delivery, + ]); + } + + /** + * Reset circuit breaker for a webhook. + */ + public function resetCircuitBreaker(Request $request, EntitlementWebhook $webhook): JsonResponse + { + $this->authorizeWebhook($request, $webhook); + + $this->webhookService->resetCircuitBreaker($webhook); + + return response()->json([ + 'message' => __('Webhook re-enabled successfully'), + 'webhook' => $webhook->refresh(), + ]); + } + + /** + * Get delivery history for a webhook. + */ + public function deliveries(Request $request, EntitlementWebhook $webhook): JsonResponse + { + $this->authorizeWebhook($request, $webhook); + + $deliveries = $webhook->deliveries() + ->latest('created_at') + ->paginate($request->integer('per_page', 50)); + + return response()->json($deliveries); + } + + /** + * Retry a failed delivery. + */ + public function retryDelivery(Request $request, EntitlementWebhookDelivery $delivery): JsonResponse + { + $this->authorizeWebhook($request, $delivery->webhook); + + if ($delivery->isSucceeded()) { + return response()->json([ + 'message' => __('Cannot retry a successful delivery'), + ], 422); + } + + $delivery = $this->webhookService->retryDelivery($delivery); + + return response()->json([ + 'message' => $delivery->isSucceeded() + ? __('Delivery retried successfully') + : __('Delivery retry failed'), + 'delivery' => $delivery, + ]); + } + + /** + * Get available event types. + */ + public function events(): JsonResponse + { + return response()->json([ + 'events' => $this->webhookService->getAvailableEvents(), + ]); + } + + /** + * Resolve the workspace from the request. + */ + protected function resolveWorkspace(Request $request): Workspace + { + // First try explicit workspace_id parameter + if ($request->has('workspace_id')) { + $workspace = Workspace::findOrFail($request->integer('workspace_id')); + + // Verify user has access + if (! $request->user()->workspaces->contains($workspace)) { + abort(403, 'You do not have access to this workspace'); + } + + return $workspace; + } + + // Fall back to user's default workspace + return $request->user()->defaultHostWorkspace() + ?? abort(400, 'No workspace specified and user has no default workspace'); + } + + /** + * Authorize that the user can access this webhook. + */ + protected function authorizeWebhook(Request $request, EntitlementWebhook $webhook): void + { + if (! $request->user()->workspaces->contains($webhook->workspace)) { + abort(403, 'You do not have access to this webhook'); + } + } +} diff --git a/src/Controllers/EntitlementApiController.php b/src/Controllers/EntitlementApiController.php new file mode 100644 index 0000000..e55edb6 --- /dev/null +++ b/src/Controllers/EntitlementApiController.php @@ -0,0 +1,493 @@ +validate([ + 'email' => 'required|email', + 'name' => 'required|string|max:255', + 'product_code' => 'required|string', + 'billing_cycle_anchor' => 'nullable|date', + 'expires_at' => 'nullable|date', + 'blesta_service_id' => 'nullable|string', + ]); + + // Find or create the user + $user = User::where('email', $validated['email'])->first(); + $isNewUser = false; + + if (! $user) { + $user = User::create([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password' => bcrypt(Str::random(32)), // Random password, user can reset + ]); + $isNewUser = true; + + // Trigger email verification notification + event(new Registered($user)); + } + + // Find the package + $package = Package::where('code', $validated['product_code'])->first(); + + if (! $package) { + return response()->json([ + 'success' => false, + 'error' => "Package '{$validated['product_code']}' not found", + ], 404); + } + + // Get or create the user's primary workspace + $workspace = $user->ownedWorkspaces()->first(); + + if (! $workspace) { + $workspace = Workspace::create([ + 'name' => $user->name."'s Workspace", + 'slug' => Str::slug($user->name).'-'.Str::random(6), + 'domain' => 'hub.host.uk.com', + 'type' => 'custom', + ]); + + // Attach user as owner + $workspace->users()->attach($user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + } + + // Provision the package + $workspacePackage = $this->entitlements->provisionPackage( + $workspace, + $package->code, + [ + 'source' => EntitlementLog::SOURCE_BLESTA, + 'billing_cycle_anchor' => $validated['billing_cycle_anchor'] + ? now()->parse($validated['billing_cycle_anchor']) + : now(), + 'expires_at' => $validated['expires_at'] + ? now()->parse($validated['expires_at']) + : null, + 'blesta_service_id' => $validated['blesta_service_id'], + 'metadata' => [ + 'created_via' => 'blesta_api', + 'client_email' => $validated['email'], + ], + ] + ); + + return response()->json([ + 'success' => true, + 'entitlement_id' => $workspacePackage->id, + 'workspace_id' => $workspace->id, + 'workspace_slug' => $workspace->slug, + 'package' => $package->code, + 'status' => $workspacePackage->status, + ], 201); + } + + /** + * Suspend an entitlement. + */ + public function suspend(Request $request, int $id): JsonResponse + { + $workspacePackage = WorkspacePackage::find($id); + + if (! $workspacePackage) { + return response()->json([ + 'success' => false, + 'error' => 'Entitlement not found', + ], 404); + } + + $workspace = $workspacePackage->workspace; + $workspacePackage->suspend(); + + EntitlementLog::logPackageAction( + $workspace, + EntitlementLog::ACTION_PACKAGE_SUSPENDED, + $workspacePackage, + source: EntitlementLog::SOURCE_BLESTA, + metadata: ['reason' => $request->input('reason', 'Suspended via Blesta')] + ); + + $this->entitlements->invalidateCache($workspace); + + return response()->json([ + 'success' => true, + 'entitlement_id' => $workspacePackage->id, + 'status' => $workspacePackage->fresh()->status, + ]); + } + + /** + * Unsuspend (reactivate) an entitlement. + */ + public function unsuspend(Request $request, int $id): JsonResponse + { + $workspacePackage = WorkspacePackage::find($id); + + if (! $workspacePackage) { + return response()->json([ + 'success' => false, + 'error' => 'Entitlement not found', + ], 404); + } + + $workspace = $workspacePackage->workspace; + $workspacePackage->reactivate(); + + EntitlementLog::logPackageAction( + $workspace, + EntitlementLog::ACTION_PACKAGE_REACTIVATED, + $workspacePackage, + source: EntitlementLog::SOURCE_BLESTA + ); + + $this->entitlements->invalidateCache($workspace); + + return response()->json([ + 'success' => true, + 'entitlement_id' => $workspacePackage->id, + 'status' => $workspacePackage->fresh()->status, + ]); + } + + /** + * Cancel an entitlement. + */ + public function cancel(Request $request, int $id): JsonResponse + { + $workspacePackage = WorkspacePackage::find($id); + + if (! $workspacePackage) { + return response()->json([ + 'success' => false, + 'error' => 'Entitlement not found', + ], 404); + } + + $workspace = $workspacePackage->workspace; + $workspacePackage->cancel(now()); + + EntitlementLog::logPackageAction( + $workspace, + EntitlementLog::ACTION_PACKAGE_CANCELLED, + $workspacePackage, + source: EntitlementLog::SOURCE_BLESTA, + metadata: ['reason' => $request->input('reason', 'Cancelled via Blesta')] + ); + + $this->entitlements->invalidateCache($workspace); + + return response()->json([ + 'success' => true, + 'entitlement_id' => $workspacePackage->id, + 'status' => $workspacePackage->fresh()->status, + ]); + } + + /** + * Renew an entitlement (extend expiry, reset usage). + */ + public function renew(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'expires_at' => 'nullable|date', + 'billing_cycle_anchor' => 'nullable|date', + ]); + + $workspacePackage = WorkspacePackage::find($id); + + if (! $workspacePackage) { + return response()->json([ + 'success' => false, + 'error' => 'Entitlement not found', + ], 404); + } + + $workspace = $workspacePackage->workspace; + + // Update dates + $updates = []; + if (isset($validated['expires_at'])) { + $updates['expires_at'] = now()->parse($validated['expires_at']); + } + if (isset($validated['billing_cycle_anchor'])) { + $updates['billing_cycle_anchor'] = now()->parse($validated['billing_cycle_anchor']); + } + + if (! empty($updates)) { + $workspacePackage->update($updates); + } + + // Expire cycle-bound boosts from the previous cycle + $this->entitlements->expireCycleBoundBoosts($workspace); + + EntitlementLog::logPackageAction( + $workspace, + EntitlementLog::ACTION_PACKAGE_RENEWED, + $workspacePackage, + source: EntitlementLog::SOURCE_BLESTA, + newValues: $updates + ); + + $this->entitlements->invalidateCache($workspace); + + return response()->json([ + 'success' => true, + 'entitlement_id' => $workspacePackage->id, + 'status' => $workspacePackage->fresh()->status, + 'expires_at' => $workspacePackage->fresh()->expires_at?->toIso8601String(), + ]); + } + + /** + * Get entitlement details. + */ + public function show(int $id): JsonResponse + { + $workspacePackage = WorkspacePackage::with(['package', 'workspace'])->find($id); + + if (! $workspacePackage) { + return response()->json([ + 'success' => false, + 'error' => 'Entitlement not found', + ], 404); + } + + return response()->json([ + 'success' => true, + 'entitlement' => [ + 'id' => $workspacePackage->id, + 'workspace_id' => $workspacePackage->workspace_id, + 'workspace_slug' => $workspacePackage->workspace->slug, + 'package_code' => $workspacePackage->package->code, + 'package_name' => $workspacePackage->package->name, + 'status' => $workspacePackage->status, + 'starts_at' => $workspacePackage->starts_at?->toIso8601String(), + 'expires_at' => $workspacePackage->expires_at?->toIso8601String(), + 'billing_cycle_anchor' => $workspacePackage->billing_cycle_anchor?->toIso8601String(), + 'blesta_service_id' => $workspacePackage->blesta_service_id, + ], + ]); + } + + // ========================================================================== + // Cross-App Entitlement API (for external services like BioHost) + // ========================================================================== + + /** + * Check if a feature is allowed for a user/workspace. + * + * Used by external apps (BioHost, etc.) to check entitlements. + * + * Query params: + * - email: User email to lookup workspace + * - feature: Feature code to check + * - quantity: Optional quantity to check (default 1) + */ + public function check(Request $request): JsonResponse + { + $validated = $request->validate([ + 'email' => 'required|email', + 'feature' => 'required|string', + 'quantity' => 'nullable|integer|min:1', + ]); + + // Find user by email + $user = User::where('email', $validated['email'])->first(); + + if (! $user) { + return response()->json([ + 'allowed' => false, + 'reason' => 'User not found', + 'feature_code' => $validated['feature'], + ], 404); + } + + // Get user's primary workspace + $workspace = $user->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json([ + 'allowed' => false, + 'reason' => 'No workspace found for user', + 'feature_code' => $validated['feature'], + ], 404); + } + + // Check entitlement + $result = $this->entitlements->can( + $workspace, + $validated['feature'], + (int) ($validated['quantity'] ?? 1) + ); + + return response()->json([ + 'allowed' => $result->isAllowed(), + 'limit' => $result->limit, + 'used' => $result->used, + 'remaining' => $result->remaining, + 'unlimited' => $result->isUnlimited(), + 'usage_percentage' => $result->getUsagePercentage(), + 'feature_code' => $validated['feature'], + 'workspace_id' => $workspace->id, + ]); + } + + /** + * Record usage for a feature. + * + * Used by external apps to record usage after an action is performed. + */ + public function recordUsage(Request $request): JsonResponse + { + $validated = $request->validate([ + 'email' => 'required|email', + 'feature' => 'required|string', + 'quantity' => 'nullable|integer|min:1', + 'metadata' => 'nullable|array', + ]); + + // Find user by email + $user = User::where('email', $validated['email'])->first(); + + if (! $user) { + return response()->json([ + 'success' => false, + 'error' => 'User not found', + ], 404); + } + + // Get user's primary workspace + $workspace = $user->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json([ + 'success' => false, + 'error' => 'No workspace found for user', + ], 404); + } + + // Record usage + $record = $this->entitlements->recordUsage( + $workspace, + $validated['feature'], + $validated['quantity'] ?? 1, + $user, + $validated['metadata'] ?? null + ); + + return response()->json([ + 'success' => true, + 'usage_record_id' => $record->id, + 'feature_code' => $validated['feature'], + 'quantity' => $validated['quantity'] ?? 1, + ], 201); + } + + /** + * Get usage summary for a workspace. + * + * Returns all features with their current usage for dashboard display. + */ + public function summary(Request $request, Workspace $workspace): JsonResponse + { + // Get active packages + $packages = $this->entitlements->getActivePackages($workspace); + + // Get active boosts + $boosts = $this->entitlements->getActiveBoosts($workspace); + + // Get usage summary grouped by category + $usageSummary = $this->entitlements->getUsageSummary($workspace); + + // Format features for response + $features = []; + foreach ($usageSummary as $category => $categoryFeatures) { + $features[$category] = collect($categoryFeatures)->map(fn ($f) => [ + 'code' => $f['code'], + 'name' => $f['name'], + 'limit' => $f['limit'], + 'used' => $f['used'], + 'remaining' => $f['remaining'], + 'unlimited' => $f['unlimited'], + 'percentage' => $f['percentage'], + ])->values()->toArray(); + } + + return response()->json([ + 'workspace_id' => $workspace->id, + 'packages' => $packages->map(fn ($wp) => [ + 'code' => $wp->package->code, + 'name' => $wp->package->name, + 'status' => $wp->status, + 'expires_at' => $wp->expires_at?->toIso8601String(), + ])->values(), + 'features' => $features, + 'boosts' => $boosts->map(fn ($b) => [ + 'feature' => $b->feature_code, + 'value' => $b->limit_value, + 'type' => $b->boost_type, + 'expires_at' => $b->expires_at?->toIso8601String(), + ])->values(), + ]); + } + + /** + * Get usage summary for the authenticated user's workspace. + */ + public function mySummary(Request $request): JsonResponse + { + $user = $request->user(); + + if (! $user) { + return response()->json([ + 'error' => 'Unauthenticated', + ], 401); + } + + $workspace = $user->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json([ + 'error' => 'No workspace found', + ], 404); + } + + return $this->summary($request, $workspace); + } +} diff --git a/src/Controllers/ReferralController.php b/src/Controllers/ReferralController.php new file mode 100644 index 0000000..2382ac1 --- /dev/null +++ b/src/Controllers/ReferralController.php @@ -0,0 +1,138 @@ +route('pricing'); + } + + // Normalise provider and model to lowercase + $provider = strtolower($provider); + $model = $model ? strtolower($model) : null; + + // Build referral data for session (includes hashed IP for fraud detection) + $referral = [ + 'provider' => $provider, + 'model' => $model, + 'referred_at' => now()->toIso8601String(), + 'ip_hash' => PrivacyHelper::hashIp($request->ip()), + ]; + + // Track the referral visit in stats (raw inbound count) + TreePlantingStats::incrementReferrals($provider, $model); + + // Store in session (primary) - includes hashed IP + $request->session()->put(self::REFERRAL_SESSION, $referral); + + // Cookie data - exclude IP for privacy (GDPR compliance) + // Provider/model is sufficient for referral attribution + $cookieData = [ + 'provider' => $provider, + 'model' => $model, + 'referred_at' => $referral['referred_at'], + ]; + + // Set 30-day cookie (backup for session expiry) + $cookie = Cookie::make( + name: self::REFERRAL_COOKIE, + value: json_encode($cookieData), + minutes: self::COOKIE_LIFETIME, + path: '/', + domain: config('session.domain'), + secure: config('app.env') === 'production', + httpOnly: true, + sameSite: 'lax' + ); + + // Redirect to pricing with ref=agent parameter + return redirect() + ->route('pricing', ['ref' => 'agent']) + ->withCookie($cookie); + } + + /** + * Get the agent referral from session or cookie. + * + * @return array{provider: string, model: ?string, referred_at: string, ip_hash?: string}|null + */ + public static function getReferral(Request $request): ?array + { + // Try session first + $referral = $request->session()->get(self::REFERRAL_SESSION); + + if ($referral) { + return $referral; + } + + // Fall back to cookie + $cookie = $request->cookie(self::REFERRAL_COOKIE); + + if ($cookie) { + try { + $decoded = json_decode($cookie, true); + if (is_array($decoded) && isset($decoded['provider'])) { + return $decoded; + } + } catch (\Throwable) { + // Cookie invalid — ignore + } + } + + return null; + } + + /** + * Clear the agent referral from session and cookie. + */ + public static function clearReferral(Request $request): void + { + $request->session()->forget(self::REFERRAL_SESSION); + Cookie::queue(Cookie::forget(self::REFERRAL_COOKIE)); + } +} diff --git a/src/Controllers/WorkspaceController.php b/src/Controllers/WorkspaceController.php new file mode 100644 index 0000000..91c7d68 --- /dev/null +++ b/src/Controllers/WorkspaceController.php @@ -0,0 +1,277 @@ +user(); + + if (! $user instanceof User) { + return $this->accessDeniedResponse('Authentication required.'); + } + + $query = $user->workspaces() + ->withCount(['users', 'bioPages']) + ->orderBy('user_workspace.is_default', 'desc') + ->orderBy('workspaces.name', 'asc'); + + // Filter by type + if ($request->has('type')) { + $query->where('type', $request->input('type')); + } + + // Filter by active status + if ($request->has('is_active')) { + $query->where('is_active', filter_var($request->input('is_active'), FILTER_VALIDATE_BOOLEAN)); + } + + // Search by name + if ($request->has('search')) { + $query->where('workspaces.name', 'like', '%'.$request->input('search').'%'); + } + + $perPage = min((int) $request->input('per_page', 25), 100); + $workspaces = $query->paginate($perPage); + + return new PaginatedCollection($workspaces, WorkspaceResource::class); + } + + /** + * Get the current workspace. + * + * GET /api/v1/workspaces/current + */ + public function current(Request $request): WorkspaceResource|JsonResponse + { + $workspace = $this->resolveWorkspace($request); + + if (! $workspace) { + return $this->noWorkspaceResponse(); + } + + $workspace->loadCount(['users', 'bioPages']); + + return new WorkspaceResource($workspace); + } + + /** + * Get a single workspace. + * + * GET /api/v1/workspaces/{workspace} + */ + public function show(Request $request, Workspace $workspace): WorkspaceResource|JsonResponse + { + $user = $request->user(); + + if (! $user instanceof User) { + return $this->accessDeniedResponse('Authentication required.'); + } + + // Verify user has access to workspace + $hasAccess = $user->workspaces() + ->where('workspaces.id', $workspace->id) + ->exists(); + + if (! $hasAccess) { + return $this->notFoundResponse('Workspace'); + } + + $workspace->loadCount(['users', 'bioPages']); + + return new WorkspaceResource($workspace); + } + + /** + * Create a new workspace. + * + * POST /api/v1/workspaces + */ + public function store(Request $request): WorkspaceResource|JsonResponse + { + $user = $request->user(); + + if (! $user instanceof User) { + return $this->accessDeniedResponse('Authentication required.'); + } + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'slug' => 'nullable|string|max:100|unique:workspaces,slug', + 'icon' => 'nullable|string|max:50', + 'color' => 'nullable|string|max:20', + 'description' => 'nullable|string|max:500', + 'type' => 'nullable|string|in:personal,team,agency,custom', + ]); + + // Generate slug if not provided + if (empty($validated['slug'])) { + $validated['slug'] = \Illuminate\Support\Str::slug($validated['name']).'-'.\Illuminate\Support\Str::random(6); + } + + // Set default domain + $validated['domain'] = 'hub.host.uk.com'; + $validated['type'] = $validated['type'] ?? 'custom'; + + $workspace = Workspace::create($validated); + + // Attach user as owner + $workspace->users()->attach($user->id, [ + 'role' => 'owner', + 'is_default' => false, + ]); + + $workspace->loadCount(['users', 'bioPages']); + + return new WorkspaceResource($workspace); + } + + /** + * Update a workspace. + * + * PUT /api/v1/workspaces/{workspace} + */ + public function update(Request $request, Workspace $workspace): WorkspaceResource|JsonResponse + { + $user = $request->user(); + + if (! $user instanceof User) { + return $this->accessDeniedResponse('Authentication required.'); + } + + // Verify user has owner/admin access + $pivot = $user->workspaces() + ->where('workspaces.id', $workspace->id) + ->first() + ?->pivot; + + if (! $pivot || ! in_array($pivot->role, ['owner', 'admin'], true)) { + return $this->accessDeniedResponse('You do not have permission to update this workspace.'); + } + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'slug' => 'sometimes|string|max:100|unique:workspaces,slug,'.$workspace->id, + 'icon' => 'nullable|string|max:50', + 'color' => 'nullable|string|max:20', + 'description' => 'nullable|string|max:500', + 'is_active' => 'sometimes|boolean', + ]); + + $workspace->update($validated); + $workspace->loadCount(['users', 'bioPages']); + + return new WorkspaceResource($workspace); + } + + /** + * Delete a workspace. + * + * DELETE /api/v1/workspaces/{workspace} + */ + public function destroy(Request $request, Workspace $workspace): JsonResponse + { + $user = $request->user(); + + if (! $user instanceof User) { + return $this->accessDeniedResponse('Authentication required.'); + } + + // Verify user is the owner + $pivot = $user->workspaces() + ->where('workspaces.id', $workspace->id) + ->first() + ?->pivot; + + if (! $pivot || $pivot->role !== 'owner') { + return $this->accessDeniedResponse('Only the workspace owner can delete a workspace.'); + } + + // Prevent deleting user's only workspace + $workspaceCount = $user->workspaces()->count(); + if ($workspaceCount <= 1) { + return response()->json([ + 'error' => 'cannot_delete', + 'message' => 'You cannot delete your only workspace.', + ], 422); + } + + $workspace->delete(); + + return response()->json(null, 204); + } + + /** + * Switch to a workspace (set as default). + * + * POST /api/v1/workspaces/{workspace}/switch + */ + public function switch(Request $request, Workspace $workspace): WorkspaceResource|JsonResponse + { + $user = $request->user(); + + if (! $user instanceof User) { + return $this->accessDeniedResponse('Authentication required.'); + } + + // Verify user has access + $hasAccess = $user->workspaces() + ->where('workspaces.id', $workspace->id) + ->exists(); + + if (! $hasAccess) { + return $this->notFoundResponse('Workspace'); + } + + // Use a single transaction with optimised query: + // Clear all defaults and set the new one in one operation using raw update + \Illuminate\Support\Facades\DB::transaction(function () use ($user, $workspace) { + // Clear all existing defaults for this user's hub workspaces + \Illuminate\Support\Facades\DB::table('user_workspace') + ->where('user_id', $user->id) + ->whereIn('workspace_id', function ($query) { + $query->select('id') + ->from('workspaces') + ->where('domain', 'hub.host.uk.com'); + }) + ->update(['is_default' => false]); + + // Set the new default + \Illuminate\Support\Facades\DB::table('user_workspace') + ->where('user_id', $user->id) + ->where('workspace_id', $workspace->id) + ->update(['is_default' => true]); + }); + + $workspace->loadCount(['users', 'bioPages']); + + return new WorkspaceResource($workspace); + } +} diff --git a/src/Controllers/WorkspaceInvitationController.php b/src/Controllers/WorkspaceInvitationController.php new file mode 100644 index 0000000..999d1ff --- /dev/null +++ b/src/Controllers/WorkspaceInvitationController.php @@ -0,0 +1,68 @@ +route('login') + ->with('error', 'This invitation link is invalid.'); + } + + // Already accepted + if ($invitation->isAccepted()) { + return redirect()->route('login') + ->with('info', 'This invitation has already been accepted.'); + } + + // Expired + if ($invitation->isExpired()) { + return redirect()->route('login') + ->with('error', 'This invitation has expired. Please ask the workspace owner to send a new invitation.'); + } + + // User not authenticated - redirect to login with intended return URL + if (! $request->user()) { + return redirect()->route('login', [ + 'email' => $invitation->email, + ])->with('invitation_token', $token) + ->with('info', "You've been invited to join {$invitation->workspace->name}. Please log in or register to accept."); + } + + // Accept the invitation + $accepted = Workspace::acceptInvitation($token, $request->user()); + + if (! $accepted) { + return redirect()->route('dashboard') + ->with('error', 'Unable to accept this invitation. It may have expired or already been used.'); + } + + // Redirect to the workspace + return redirect()->route('workspace.home', ['workspace' => $invitation->workspace->slug]) + ->with('success', "You've joined {$invitation->workspace->name}."); + } +} diff --git a/src/Database/Factories/UserFactory.php b/src/Database/Factories/UserFactory.php new file mode 100644 index 0000000..3a56e26 --- /dev/null +++ b/src/Database/Factories/UserFactory.php @@ -0,0 +1,73 @@ + + */ +class UserFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * Uses the backward-compatible alias class to ensure type compatibility + * with existing code that expects Mod\Tenant\Models\User. + */ + protected $model = \Core\Mod\Tenant\Models\User::class; + + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + 'account_type' => 'apollo', + ]; + } + + /** + * Create a Hades (admin) user. + */ + public function hades(): static + { + return $this->state(fn (array $attributes) => [ + 'account_type' => 'hades', + ]); + } + + /** + * Create an Apollo (standard) user. + */ + public function apollo(): static + { + return $this->state(fn (array $attributes) => [ + 'account_type' => 'apollo', + ]); + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/src/Database/Factories/UserTokenFactory.php b/src/Database/Factories/UserTokenFactory.php new file mode 100644 index 0000000..dab5b03 --- /dev/null +++ b/src/Database/Factories/UserTokenFactory.php @@ -0,0 +1,87 @@ + + */ +class UserTokenFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = UserToken::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $plainToken = Str::random(40); + + return [ + 'user_id' => User::factory(), + 'name' => fake()->words(2, true).' Token', + 'token' => hash('sha256', $plainToken), + 'last_used_at' => null, + 'expires_at' => null, + ]; + } + + /** + * Indicate that the token has been used recently. + */ + public function used(): static + { + return $this->state(fn (array $attributes) => [ + 'last_used_at' => now()->subMinutes(fake()->numberBetween(1, 60)), + ]); + } + + /** + * Indicate that the token expires in the future. + * + * @param int $days Number of days until expiration + */ + public function expiresIn(int $days = 30): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->addDays($days), + ]); + } + + /** + * Indicate that the token has expired. + */ + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->subDays(1), + ]); + } + + /** + * Create a token with a known plain-text value for testing. + * + * @param string $plainToken The plain-text token value + */ + public function withToken(string $plainToken): static + { + return $this->state(fn (array $attributes) => [ + 'token' => hash('sha256', $plainToken), + ]); + } +} diff --git a/src/Database/Factories/WaitlistEntryFactory.php b/src/Database/Factories/WaitlistEntryFactory.php new file mode 100644 index 0000000..01ca0dd --- /dev/null +++ b/src/Database/Factories/WaitlistEntryFactory.php @@ -0,0 +1,59 @@ + + */ +class WaitlistEntryFactory extends Factory +{ + protected $model = WaitlistEntry::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'email' => fake()->unique()->safeEmail(), + 'name' => fake()->optional(0.8)->name(), + 'source' => fake()->randomElement(['direct', 'twitter', 'linkedin', 'google', 'referral']), + 'interest' => fake()->optional(0.5)->randomElement(['SocialHost', 'BioHost', 'AnalyticsHost', 'TrustHost', 'NotifyHost']), + 'invite_code' => null, + 'invited_at' => null, + 'registered_at' => null, + 'user_id' => null, + 'notes' => null, + 'bonus_code' => null, + ]; + } + + /** + * Indicate the entry has been invited. + */ + public function invited(): static + { + return $this->state(fn (array $attributes) => [ + 'invite_code' => strtoupper(fake()->bothify('????????')), + 'invited_at' => fake()->dateTimeBetween('-30 days', 'now'), + 'bonus_code' => 'LAUNCH50', + ]); + } + + /** + * Indicate the entry has converted to a user. + */ + public function converted(): static + { + return $this->invited()->state(fn (array $attributes) => [ + 'registered_at' => fake()->dateTimeBetween($attributes['invited_at'] ?? '-7 days', 'now'), + ]); + } +} diff --git a/src/Database/Factories/WorkspaceFactory.php b/src/Database/Factories/WorkspaceFactory.php new file mode 100644 index 0000000..55f4cc2 --- /dev/null +++ b/src/Database/Factories/WorkspaceFactory.php @@ -0,0 +1,81 @@ + + */ +class WorkspaceFactory extends Factory +{ + protected $model = Workspace::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $name = fake()->company(); + $slug = fake()->unique()->slug(2); + + return [ + 'name' => $name, + 'slug' => $slug, + 'domain' => $slug.'.host.uk.com', + 'icon' => fake()->randomElement(['globe', 'building', 'newspaper', 'megaphone']), + 'color' => fake()->randomElement(['violet', 'blue', 'green', 'amber', 'rose']), + 'description' => fake()->sentence(), + 'type' => 'cms', + 'settings' => [], + 'is_active' => true, + 'sort_order' => fake()->numberBetween(1, 100), + ]; + } + + /** + * Create a CMS workspace. + */ + public function cms(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'cms', + ]); + } + + /** + * Create a static workspace. + */ + public function static(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'static', + ]); + } + + /** + * Create an inactive workspace. + */ + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } + + /** + * Create the main workspace (used in tests). + */ + public function main(): static + { + return $this->state(fn (array $attributes) => [ + 'name' => 'Host UK', + 'slug' => 'main', + 'domain' => 'hestia.host.uk.com', + 'type' => 'cms', + ]); + } +} diff --git a/src/Database/Factories/WorkspaceInvitationFactory.php b/src/Database/Factories/WorkspaceInvitationFactory.php new file mode 100644 index 0000000..c1771b2 --- /dev/null +++ b/src/Database/Factories/WorkspaceInvitationFactory.php @@ -0,0 +1,75 @@ + + */ +class WorkspaceInvitationFactory extends Factory +{ + protected $model = WorkspaceInvitation::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'email' => fake()->unique()->safeEmail(), + 'token' => Str::random(64), + 'role' => 'member', + 'invited_by' => null, + 'expires_at' => now()->addDays(7), + 'accepted_at' => null, + ]; + } + + /** + * Indicate the invitation has been accepted. + */ + public function accepted(): static + { + return $this->state(fn (array $attributes) => [ + 'accepted_at' => fake()->dateTimeBetween('-7 days', 'now'), + ]); + } + + /** + * Indicate the invitation has expired. + */ + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => fake()->dateTimeBetween('-30 days', '-1 day'), + 'accepted_at' => null, + ]); + } + + /** + * Set the role to admin. + */ + public function asAdmin(): static + { + return $this->state(fn (array $attributes) => [ + 'role' => 'admin', + ]); + } + + /** + * Set the role to owner. + */ + public function asOwner(): static + { + return $this->state(fn (array $attributes) => [ + 'role' => 'owner', + ]); + } +} diff --git a/src/Database/Seeders/DemoTestUserSeeder.php b/src/Database/Seeders/DemoTestUserSeeder.php new file mode 100644 index 0000000..d1da763 --- /dev/null +++ b/src/Database/Seeders/DemoTestUserSeeder.php @@ -0,0 +1,170 @@ + self::EMAIL], + [ + 'name' => 'Nyx Tester', + 'password' => Hash::make(self::PASSWORD), + 'email_verified_at' => now(), + ] + ); + + // Create or update Nyx demo workspace + $workspace = Workspace::updateOrCreate( + ['slug' => self::WORKSPACE_SLUG], + [ + 'name' => 'Nyx Demo Workspace', + 'domain' => 'nyx.host.uk.com', + 'is_active' => true, + ] + ); + + // Attach user to workspace (if not already) + if (! $workspace->users()->where('user_id', $user->id)->exists()) { + $workspace->users()->attach($user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + } + + // Assign Nyx package (Lethean Network demo tier) + $nyxPackage = Package::where('code', 'nyx')->first(); + if ($nyxPackage) { + // Remove any existing packages + $workspace->workspacePackages()->delete(); + + // Create Nyx package assignment + $workspace->workspacePackages()->create([ + 'package_id' => $nyxPackage->id, + 'status' => 'active', + 'starts_at' => now(), + 'expires_at' => null, // No expiry for test account + ]); + } + + // Create minimal test data for the workspace + $this->createTestBioPage($workspace, $user); + $this->createTestShortLink($workspace, $user); + + $this->command->info('Nyx demo user created successfully.'); + $this->command->info("Email: {$user->email}"); + $this->command->info('Password: '.self::PASSWORD); + $this->command->info("Workspace: {$workspace->slug}"); + $this->command->info('Tier: Nyx (Lethean Network)'); + } + + /** + * Create a single test bio page. + */ + protected function createTestBioPage(Workspace $workspace, User $user): void + { + // Only create if Web Page model exists and no test page exists + if (! class_exists(\Core\Mod\Web\Models\Page::class)) { + return; + } + + $existingPage = \Core\Mod\Web\Models\Page::where('workspace_id', $workspace->id) + ->where('url', 'nyx-test') + ->first(); + + if ($existingPage) { + return; + } + + \Core\Mod\Web\Models\Page::create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'url' => 'nyx-test', + 'type' => 'page', + 'settings' => [ + 'name' => 'Nyx Test Page', + 'description' => 'Test page for Playwright acceptance tests (Lethean Network)', + 'title' => 'Nyx Test', + 'blocks' => [ + [ + 'id' => 'header-1', + 'type' => 'header', + 'data' => [ + 'name' => 'Nyx Tester', + 'bio' => 'Lethean Network demo account', + ], + ], + [ + 'id' => 'link-1', + 'type' => 'link', + 'data' => [ + 'title' => 'Test Link', + 'url' => 'https://example.com', + ], + ], + ], + 'theme' => 'default', + 'show_branding' => true, + ], + 'is_enabled' => true, + ]); + } + + /** + * Create a single test short link. + */ + protected function createTestShortLink(Workspace $workspace, User $user): void + { + // Only create if Web Page model exists + if (! class_exists(\Core\Mod\Web\Models\Page::class)) { + return; + } + + $existingLink = \Core\Mod\Web\Models\Page::where('workspace_id', $workspace->id) + ->where('url', 'nyx-short') + ->first(); + + if ($existingLink) { + return; + } + + \Core\Mod\Web\Models\Page::create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'url' => 'nyx-short', + 'type' => 'link', + 'location_url' => 'https://host.uk.com', + 'is_enabled' => true, + ]); + } +} diff --git a/src/Database/Seeders/DemoWorkspaceSeeder.php b/src/Database/Seeders/DemoWorkspaceSeeder.php new file mode 100644 index 0000000..0c528e7 --- /dev/null +++ b/src/Database/Seeders/DemoWorkspaceSeeder.php @@ -0,0 +1,165 @@ +createDemoPackages(); + + // Create demo workspaces + $workspaces = [ + [ + 'name' => 'Demo Social', + 'slug' => 'demo-social', + 'domain' => 'demo-social.host.test', + 'description' => 'Demo workspace with SocialHost access', + 'icon' => 'share-nodes', + 'color' => 'green', + 'package' => 'demo-social', + ], + [ + 'name' => 'Demo Trust', + 'slug' => 'demo-trust', + 'domain' => 'demo-trust.host.test', + 'description' => 'Demo workspace with TrustHost access', + 'icon' => 'shield-check', + 'color' => 'orange', + 'package' => 'demo-trust', + ], + [ + 'name' => 'Demo No Services', + 'slug' => 'demo-no-services', + 'domain' => 'demo-free.host.test', + 'description' => 'Demo workspace with no service access', + 'icon' => 'user', + 'color' => 'gray', + 'package' => null, + ], + ]; + + foreach ($workspaces as $data) { + $workspace = Workspace::updateOrCreate( + ['slug' => $data['slug']], + [ + 'name' => $data['name'], + 'domain' => $data['domain'], + 'description' => $data['description'], + 'icon' => $data['icon'], + 'color' => $data['color'], + 'type' => 'custom', + 'is_active' => true, + ] + ); + + // Provision package if specified + if ($data['package']) { + $entitlements->provisionPackage($workspace, $data['package']); + } + + $this->command->info("Created demo workspace: {$data['name']}"); + } + + // Create demo user and attach to workspaces + $this->createDemoUser($workspaces); + } + + protected function createDemoPackages(): void + { + // Demo Social Package - SocialHost access + $socialPackage = Package::updateOrCreate( + ['code' => 'demo-social'], + [ + 'name' => 'Demo Social', + 'description' => 'Demo package with SocialHost access', + 'is_stackable' => false, + 'is_base_package' => true, + 'is_active' => true, + 'is_public' => false, + 'sort_order' => 900, + ] + ); + + // Attach service gate + $hostSocial = Feature::where('code', 'core.srv.social')->first(); + if ($hostSocial && ! $socialPackage->features()->where('feature_id', $hostSocial->id)->exists()) { + $socialPackage->features()->attach($hostSocial->id, ['limit_value' => null]); + } + + // Attach social features with limits + $socialAccounts = Feature::where('code', 'social.accounts')->first(); + if ($socialAccounts && ! $socialPackage->features()->where('feature_id', $socialAccounts->id)->exists()) { + $socialPackage->features()->attach($socialAccounts->id, ['limit_value' => 5]); + } + + $socialPosts = Feature::where('code', 'social.posts.scheduled')->first(); + if ($socialPosts && ! $socialPackage->features()->where('feature_id', $socialPosts->id)->exists()) { + $socialPackage->features()->attach($socialPosts->id, ['limit_value' => 50]); + } + + // Demo Trust Package - TrustHost access + $trustPackage = Package::updateOrCreate( + ['code' => 'demo-trust'], + [ + 'name' => 'Demo Trust', + 'description' => 'Demo package with TrustHost access', + 'is_stackable' => false, + 'is_base_package' => true, + 'is_active' => true, + 'is_public' => false, + 'sort_order' => 901, + ] + ); + + // Attach service gate + $hostTrust = Feature::where('code', 'core.srv.trust')->first(); + if ($hostTrust && ! $trustPackage->features()->where('feature_id', $hostTrust->id)->exists()) { + $trustPackage->features()->attach($hostTrust->id, ['limit_value' => null]); + } + + $this->command->info('Demo packages created.'); + } + + protected function createDemoUser(array $workspaces): void + { + // Find primary admin user, or create demo user as fallback + $user = User::where('email', 'snider@host.uk.com')->first() + ?? User::updateOrCreate( + ['email' => 'demo@host.uk.com'], + [ + 'name' => 'Demo User', + 'password' => bcrypt('demo-password-123'), + 'email_verified_at' => now(), + ] + ); + + // Attach to all demo workspaces + foreach ($workspaces as $data) { + $workspace = Workspace::where('slug', $data['slug'])->first(); + if ($workspace && ! $workspace->users()->where('user_id', $user->id)->exists()) { + $workspace->users()->attach($user->id, [ + 'role' => 'owner', + 'is_default' => false, // Don't change their default workspace + ]); + } + } + + $this->command->info("Demo workspaces attached to: {$user->email}"); + } +} diff --git a/src/Database/Seeders/FeatureSeeder.php b/src/Database/Seeders/FeatureSeeder.php new file mode 100644 index 0000000..0a56794 --- /dev/null +++ b/src/Database/Seeders/FeatureSeeder.php @@ -0,0 +1,901 @@ + 'tier.apollo', + 'name' => 'Apollo Tier', + 'description' => 'Access to Apollo tier features', + 'category' => 'tier', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 1, + ], + [ + 'code' => 'tier.hades', + 'name' => 'Hades Tier', + 'description' => 'Access to Hades tier features (developer tools)', + 'category' => 'tier', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 2, + ], + // Lethean Network designations + [ + 'code' => 'tier.nyx', + 'name' => 'Nyx Tier', + 'description' => 'Demo/test account access (Lethean Network)', + 'category' => 'tier', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 3, + ], + [ + 'code' => 'tier.stygian', + 'name' => 'Stygian Tier', + 'description' => 'Standard user access (Lethean Network)', + 'category' => 'tier', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 4, + ], + // Corporate Sponsors (Lethean Network) + [ + 'code' => 'tier.plouton', + 'name' => 'Ploutōn Tier', + 'description' => 'White label partner access (Lethean Network)', + 'category' => 'tier', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 5, + ], + [ + 'code' => 'tier.hermes', + 'name' => 'Hermes Tier', + 'description' => 'Founding patron access - seat in Elysia (Lethean Network)', + 'category' => 'tier', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 6, + ], + + // Service access gates (deny by default) + [ + 'code' => 'core.srv.social', + 'name' => 'SocialHost Access', + 'description' => 'Access to SocialHost social media management', + 'category' => 'service', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 1, + ], + [ + 'code' => 'core.srv.bio', + 'name' => 'BioHost Access', + 'description' => 'Access to BioHost link-in-bio pages', + 'category' => 'service', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 2, + ], + [ + 'code' => 'core.srv.analytics', + 'name' => 'AnalyticsHost Access', + 'description' => 'Access to AnalyticsHost privacy-focused analytics', + 'category' => 'service', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 3, + ], + [ + 'code' => 'core.srv.trust', + 'name' => 'TrustHost Access', + 'description' => 'Access to TrustHost social proof notifications', + 'category' => 'service', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 4, + ], + [ + 'code' => 'core.srv.notify', + 'name' => 'NotifyHost Access', + 'description' => 'Access to NotifyHost push notifications', + 'category' => 'service', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 5, + ], + [ + 'code' => 'core.srv.support', + 'name' => 'SupportHost Access', + 'description' => 'Access to SupportHost help desk', + 'category' => 'service', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 6, + ], + [ + 'code' => 'core.srv.web', + 'name' => 'WebHost Access', + 'description' => 'Access to WebHost site management', + 'category' => 'service', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 7, + ], + [ + 'code' => 'core.srv.commerce', + 'name' => 'Commerce Access', + 'description' => 'Access to Commerce store management', + 'category' => 'service', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 8, + ], + [ + 'code' => 'core.srv.hub', + 'name' => 'Hub Access', + 'description' => 'Access to Hub admin panel', + 'category' => 'service', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 0, // Internal service + ], + [ + 'code' => 'core.srv.agentic', + 'name' => 'Agentic Access', + 'description' => 'Access to AI agent services', + 'category' => 'service', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 9, + ], + + // Social features + [ + 'code' => 'social.accounts', + 'name' => 'Social Accounts', + 'description' => 'Number of connected social media accounts', + 'category' => 'social', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 1, + ], + [ + 'code' => 'social.posts.scheduled', + 'name' => 'Scheduled Posts', + 'description' => 'Number of scheduled posts per month', + 'category' => 'social', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_MONTHLY, + 'sort_order' => 2, + ], + [ + 'code' => 'social.workspaces', + 'name' => 'Social Workspaces', + 'description' => 'Number of social workspaces', + 'category' => 'social', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 3, + ], + [ + 'code' => 'social.posts.bulk', + 'name' => 'Bulk Post Upload', + 'description' => 'Upload multiple posts via CSV/bulk import', + 'category' => 'social', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 4, + ], + [ + 'code' => 'social.analytics', + 'name' => 'Social Analytics', + 'description' => 'Access to social media analytics', + 'category' => 'social', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 5, + ], + [ + 'code' => 'social.analytics.advanced', + 'name' => 'Advanced Analytics', + 'description' => 'Advanced reporting and analytics features', + 'category' => 'social', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 6, + ], + [ + 'code' => 'social.team', + 'name' => 'Team Collaboration', + 'description' => 'Multi-user team features for social management', + 'category' => 'social', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 7, + ], + [ + 'code' => 'social.approval_workflow', + 'name' => 'Approval Workflow', + 'description' => 'Content approval workflow before posting', + 'category' => 'social', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 8, + ], + [ + 'code' => 'social.white_label', + 'name' => 'White Label', + 'description' => 'Remove SocialHost branding', + 'category' => 'social', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 9, + ], + [ + 'code' => 'social.api_access', + 'name' => 'Social API Access', + 'description' => 'Access to SocialHost API', + 'category' => 'social', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 10, + ], + [ + 'code' => 'social.templates', + 'name' => 'Post Templates', + 'description' => 'Number of saved post templates', + 'category' => 'social', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 11, + ], + [ + 'code' => 'social.hashtag_groups', + 'name' => 'Hashtag Groups', + 'description' => 'Number of saved hashtag groups', + 'category' => 'social', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 12, + ], + [ + 'code' => 'social.ai_suggestions', + 'name' => 'AI Content Suggestions', + 'description' => 'AI-powered caption generation and content improvement', + 'category' => 'social', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 13, + ], + + // AI features + [ + 'code' => 'ai.credits', + 'name' => 'AI Credits', + 'description' => 'AI generation credits per month', + 'category' => 'ai', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_MONTHLY, + 'sort_order' => 1, + ], + [ + 'code' => 'ai.providers.claude', + 'name' => 'Claude AI', + 'description' => 'Access to Claude AI provider', + 'category' => 'ai', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 2, + ], + [ + 'code' => 'ai.providers.gemini', + 'name' => 'Gemini AI', + 'description' => 'Access to Gemini AI provider', + 'category' => 'ai', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 3, + ], + + // Team features + [ + 'code' => 'team.members', + 'name' => 'Team Members', + 'description' => 'Number of team members per workspace', + 'category' => 'team', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 1, + ], + + // API features + [ + 'code' => 'api.requests', + 'name' => 'API Requests', + 'description' => 'API requests per 30 days (rolling)', + 'category' => 'api', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_ROLLING, + 'rolling_window_days' => 30, + 'sort_order' => 1, + ], + + // MCP Quota features + [ + 'code' => 'mcp.monthly_tool_calls', + 'name' => 'MCP Tool Calls', + 'description' => 'Monthly limit for MCP tool calls', + 'category' => 'mcp', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_MONTHLY, + 'sort_order' => 1, + ], + [ + 'code' => 'mcp.monthly_tokens', + 'name' => 'MCP Tokens', + 'description' => 'Monthly limit for MCP token consumption', + 'category' => 'mcp', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_MONTHLY, + 'sort_order' => 2, + ], + + // Storage - Global pool + [ + 'code' => 'core.res.storage.total', + 'name' => 'Total Storage', + 'description' => 'Total storage across all services (MB)', + 'category' => 'storage', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 1, + ], + + // ───────────────────────────────────────────────────────────── + // lt.hn Pricing Features (numeric - ordered by sort_order) + // ───────────────────────────────────────────────────────────── + [ + 'code' => 'bio.pages', + 'name' => 'Bio Pages', + 'description' => 'Number of pages allowed', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 10, + ], + [ + 'code' => 'webpage.sub_pages', + 'name' => 'Sub-Pages', + 'description' => 'Additional pages under your main page', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 20, + ], + [ + 'code' => 'bio.blocks', + 'name' => 'Page Blocks', + 'description' => 'Number of blocks per page', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 30, + ], + [ + 'code' => 'bio.static_sites', + 'name' => 'Static Websites', + 'description' => 'Number of static websites allowed', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 40, + ], + [ + 'code' => 'bio.custom_domains', + 'name' => 'Custom Domains', + 'description' => 'Number of custom domains allowed', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 50, + ], + [ + 'code' => 'bio.web3_domains', + 'name' => 'Web3 Domains', + 'description' => 'Number of Web3 domains (ENS, etc.)', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 60, + ], + [ + 'code' => 'bio.vcard', + 'name' => 'vCard', + 'description' => 'Number of vCard downloads allowed', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 70, + ], + [ + 'code' => 'bio.events', + 'name' => 'Events', + 'description' => 'Number of event blocks allowed', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 80, + ], + [ + 'code' => 'bio.file_downloads', + 'name' => 'File Downloads', + 'description' => 'Number of file download blocks allowed', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 90, + ], + [ + 'code' => 'bio.splash_pages', + 'name' => 'Splash Pages', + 'description' => 'Number of splash/landing pages allowed', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 100, + ], + [ + 'code' => 'bio.shortened_links', + 'name' => 'Shortened Links', + 'description' => 'Number of shortened links allowed', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 110, + ], + [ + 'code' => 'bio.pixels', + 'name' => 'Pixels', + 'description' => 'Number of tracking pixels allowed', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 120, + ], + [ + 'code' => 'bio.qr_codes', + 'name' => 'QR Codes', + 'description' => 'Number of QR codes allowed', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 130, + ], + // ───────────────────────────────────────────────────────────── + // lt.hn Pricing Features (boolean - ordered by sort_order) + // ───────────────────────────────────────────────────────────── + [ + 'code' => 'bio.analytics.basic', + 'name' => 'Basic Analytics', + 'description' => 'Basic analytics for pages', + 'category' => 'web', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 200, + ], + [ + 'code' => 'support.community', + 'name' => 'Community Support', + 'description' => 'Access to community support', + 'category' => 'support', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 210, + ], + [ + 'code' => 'support.host.uk.com', + 'name' => 'Support', + 'description' => 'Email support access', + 'category' => 'support', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 211, + ], + [ + 'code' => 'support.priority', + 'name' => 'Priority Support', + 'description' => 'Priority support access', + 'category' => 'support', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 212, + ], + [ + 'code' => 'bio.themes', + 'name' => 'Themes', + 'description' => 'Access to page themes', + 'category' => 'web', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 220, + ], + // ───────────────────────────────────────────────────────────── + // Legacy Bio features (internal use) + // ───────────────────────────────────────────────────────────── + [ + 'code' => 'bio.shortlinks', + 'name' => 'Short Links', + 'description' => 'Number of short links allowed', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 150, + ], + [ + 'code' => 'bio.static', + 'name' => 'Static Pages', + 'description' => 'Number of static HTML pages allowed', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 151, + ], + [ + 'code' => 'bio.domains', + 'name' => 'Custom Domains (Legacy)', + 'description' => 'Number of custom domains allowed', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 152, + ], + [ + 'code' => 'bio.analytics_days', + 'name' => 'Analytics Retention', + 'description' => 'Days of analytics history retained', + 'category' => 'web', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 5, + ], + [ + 'code' => 'bio.tier.pro', + 'name' => 'Pro Block Types', + 'description' => 'Access to pro-tier block types', + 'category' => 'web', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 6, + ], + [ + 'code' => 'bio.tier.ultimate', + 'name' => 'Ultimate Block Types', + 'description' => 'Access to ultimate-tier block types', + 'category' => 'web', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 7, + ], + [ + 'code' => 'bio.tier.payment', + 'name' => 'Payment Block Types', + 'description' => 'Access to payment block types', + 'category' => 'web', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 8, + ], + [ + 'code' => 'web.themes.premium', + 'name' => 'Premium Themes', + 'description' => 'Access to premium page themes', + 'category' => 'web', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 9, + ], + [ + 'code' => 'bio.pwa', + 'name' => 'Progressive Web App', + 'description' => 'Turn pages into installable apps', + 'category' => 'web', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 230, + ], + + // Content features (native CMS) + [ + 'code' => 'content.mcp_access', + 'name' => 'Content MCP Access', + 'description' => 'Access to content management via MCP tools', + 'category' => 'content', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 1, + ], + [ + 'code' => 'content.items', + 'name' => 'Content Items', + 'description' => 'Number of content items (posts, pages)', + 'category' => 'content', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 2, + ], + [ + 'code' => 'content.ai_generation', + 'name' => 'AI Content Generation', + 'description' => 'Generate content using AI via MCP', + 'category' => 'content', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 3, + ], + + // Analytics features + [ + 'code' => 'analytics.sites', + 'name' => 'Analytics Sites', + 'description' => 'Number of sites to track', + 'category' => 'analytics', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 1, + ], + [ + 'code' => 'analytics.pageviews', + 'name' => 'Monthly Pageviews', + 'description' => 'Pageviews tracked per month', + 'category' => 'analytics', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_MONTHLY, + 'sort_order' => 2, + ], + + // Support features + [ + 'code' => 'support.mailboxes', + 'name' => 'Mailboxes', + 'description' => 'Number of support mailboxes', + 'category' => 'support', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 1, + ], + [ + 'code' => 'support.agents', + 'name' => 'Support Agents', + 'description' => 'Number of support agents', + 'category' => 'support', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 2, + ], + [ + 'code' => 'support.conversations', + 'name' => 'Conversations per Month', + 'description' => 'Number of conversations per month', + 'category' => 'support', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_MONTHLY, + 'sort_order' => 3, + ], + [ + 'code' => 'support.chat_widget', + 'name' => 'Live Chat Widget', + 'description' => 'Enable live chat widget', + 'category' => 'support', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 4, + ], + [ + 'code' => 'support.saved_replies', + 'name' => 'Saved Replies', + 'description' => 'Number of saved reply templates', + 'category' => 'support', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 5, + ], + [ + 'code' => 'support.custom_folders', + 'name' => 'Custom Folders', + 'description' => 'Enable custom folder organisation', + 'category' => 'support', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 6, + ], + [ + 'code' => 'support.api_access', + 'name' => 'API Access', + 'description' => 'Access to Support API endpoints', + 'category' => 'support', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 7, + ], + [ + 'code' => 'support.auto_reply', + 'name' => 'Auto Reply', + 'description' => 'Automatic reply to incoming messages', + 'category' => 'support', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 8, + ], + [ + 'code' => 'support.email_templates', + 'name' => 'Email Templates', + 'description' => 'Number of email templates', + 'category' => 'support', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 9, + ], + [ + 'code' => 'support.file_storage_mb', + 'name' => 'File Storage (MB)', + 'description' => 'File attachment storage in megabytes', + 'category' => 'support', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 10, + ], + [ + 'code' => 'support.retention_days', + 'name' => 'Retention Days', + 'description' => 'Number of days to retain conversation history', + 'category' => 'support', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 11, + ], + + // Tools features (utility tools access) + [ + 'code' => 'tool.mcp_access', + 'name' => 'Tools MCP Access', + 'description' => 'Access to utility tools via MCP API', + 'category' => 'tools', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 1, + ], + [ + 'code' => 'tool.url_shortener', + 'name' => 'URL Shortener', + 'description' => 'Create persistent short links with analytics', + 'category' => 'tools', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 2, + ], + [ + 'code' => 'tool.qr_generator', + 'name' => 'QR Code Generator', + 'description' => 'Create and save QR codes', + 'category' => 'tools', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 3, + ], + [ + 'code' => 'tool.dns_lookup', + 'name' => 'DNS Lookup', + 'description' => 'DNS record lookup tool', + 'category' => 'tools', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 4, + ], + [ + 'code' => 'tool.ssl_lookup', + 'name' => 'SSL Lookup', + 'description' => 'SSL certificate lookup tool', + 'category' => 'tools', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 5, + ], + [ + 'code' => 'tool.whois_lookup', + 'name' => 'WHOIS Lookup', + 'description' => 'Domain WHOIS lookup tool', + 'category' => 'tools', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 6, + ], + [ + 'code' => 'tool.ip_lookup', + 'name' => 'IP Lookup', + 'description' => 'IP address geolocation lookup', + 'category' => 'tools', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 7, + ], + [ + 'code' => 'tool.http_headers', + 'name' => 'HTTP Headers', + 'description' => 'HTTP header inspection tool', + 'category' => 'tools', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'sort_order' => 8, + ], + ]; + + foreach ($features as $featureData) { + Feature::updateOrCreate( + ['code' => $featureData['code']], + $featureData + ); + } + + // Create child features for storage pool + $storageParent = Feature::where('code', 'core.res.storage.total')->first(); + if ($storageParent) { + $storageChildren = [ + [ + 'code' => 'core.res.cdn', + 'name' => 'Main Site CDN', + 'description' => 'CDN storage for main site (MB)', + 'category' => 'storage', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'parent_feature_id' => $storageParent->id, + 'sort_order' => 2, + ], + [ + 'code' => 'bio.cdn', + 'name' => 'Bio CDN', + 'description' => 'CDN storage for bio pages (MB)', + 'category' => 'storage', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'parent_feature_id' => $storageParent->id, + 'sort_order' => 3, + ], + [ + 'code' => 'social.cdn', + 'name' => 'Social CDN', + 'description' => 'CDN storage for social media (MB)', + 'category' => 'storage', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'parent_feature_id' => $storageParent->id, + 'sort_order' => 4, + ], + ]; + + foreach ($storageChildren as $childData) { + Feature::updateOrCreate( + ['code' => $childData['code']], + $childData + ); + } + } + + $this->command->info('Features seeded successfully.'); + } +} diff --git a/src/Database/Seeders/SystemWorkspaceSeeder.php b/src/Database/Seeders/SystemWorkspaceSeeder.php new file mode 100644 index 0000000..f2f4418 --- /dev/null +++ b/src/Database/Seeders/SystemWorkspaceSeeder.php @@ -0,0 +1,57 @@ +first(); + + if (! $hermes) { + $this->command->error('Hermes package not found. Run PackageSeeder first.'); + + return; + } + + // Assign to both main and system workspaces + $slugs = ['main', 'system']; + + foreach ($slugs as $slug) { + $workspace = Workspace::where('slug', $slug)->first(); + + if (! $workspace) { + $this->command->warn("Workspace '{$slug}' not found, skipping."); + + continue; + } + + $existing = WorkspacePackage::where('workspace_id', $workspace->id) + ->where('package_id', $hermes->id) + ->first(); + + if ($existing) { + $this->command->info('Hermes already assigned to '.$workspace->name); + + continue; + } + + WorkspacePackage::create([ + 'workspace_id' => $workspace->id, + 'package_id' => $hermes->id, + 'status' => WorkspacePackage::STATUS_ACTIVE, + 'starts_at' => now(), + ]); + + $this->command->info('Hermes assigned to '.$workspace->name); + } + } +} diff --git a/src/Database/Seeders/WorkspaceSeeder.php b/src/Database/Seeders/WorkspaceSeeder.php new file mode 100644 index 0000000..bb843f3 --- /dev/null +++ b/src/Database/Seeders/WorkspaceSeeder.php @@ -0,0 +1,183 @@ +environment('local'); + $domain = $isLocal ? 'host.test' : 'host.uk.com'; + $email = 'snider@host.uk.com'; + + // Create system user first so we can assign ownership + $systemUser = User::updateOrCreate( + ['id' => 1], + [ + 'name' => 'Snider', + 'email' => $email, + 'password' => Hash::make('change-me-in-env'), + 'tier' => UserTier::HADES, + 'tier_expires_at' => null, + 'email_verified_at' => now(), + ] + ); + + // Service workspaces - marketing domains are handled by Mod modules, not workspace routing. + // The workspace domain field is for custom user-assigned domains (e.g., mybrand.com). + // Service domains (lthn.test, social.host.test, etc.) are routed via Mod\{Service}\Boot. + $workspaces = [ + [ + 'name' => 'Host UK', + 'slug' => 'main', + 'domain' => $domain, // Main marketing site + 'icon' => 'globe', + 'color' => 'violet', + 'description' => 'Main website content', + 'type' => 'cms', + 'sort_order' => 0, + ], + [ + 'name' => 'Social', + 'slug' => 'social', + 'domain' => '', // Marketing domain routed via Mod\Social + 'icon' => 'share-nodes', + 'color' => 'green', + 'description' => 'Social media scheduling', + 'type' => 'custom', + 'sort_order' => 2, + ], + [ + 'name' => 'Analytics', + 'slug' => 'analytics', + 'domain' => '', // Marketing domain routed via Mod\Analytics + 'icon' => 'chart-line', + 'color' => 'yellow', + 'description' => 'Privacy-first analytics', + 'type' => 'custom', + 'sort_order' => 3, + ], + [ + 'name' => 'Trust', + 'slug' => 'trust', + 'domain' => '', // Marketing domain routed via Mod\Trust + 'icon' => 'shield-check', + 'color' => 'orange', + 'description' => 'Social proof widgets', + 'type' => 'custom', + 'sort_order' => 4, + ], + [ + 'name' => 'Notify', + 'slug' => 'notify', + 'domain' => '', // Marketing domain routed via Mod\Notify + 'icon' => 'bell', + 'color' => 'red', + 'description' => 'Push notifications', + 'type' => 'custom', + 'sort_order' => 5, + ], + [ + 'name' => 'LtHn', + 'slug' => 'lthn', + 'domain' => '', // Marketing domain routed via Mod\LtHn + 'icon' => 'link', + 'color' => 'cyan', + 'description' => 'lt.hn bio link service', + 'type' => 'custom', + 'sort_order' => 6, + ], + ]; + + foreach ($workspaces as $workspace) { + $ws = Workspace::updateOrCreate( + ['slug' => $workspace['slug']], + array_merge($workspace, ['is_active' => true]) + ); + + // Attach system user as owner if not already attached + if (! $ws->users()->where('user_id', $systemUser->id)->exists()) { + $ws->users()->attach($systemUser->id, [ + 'role' => 'owner', + 'is_default' => false, + ]); + } + } + + // Provision hades to main workspace only + $this->provisionWorkspaceEntitlements(); + } + + /** + * Provision packages for workspaces. + */ + protected function provisionWorkspaceEntitlements(): void + { + if (! Schema::hasTable('entitlement_workspace_packages')) { + return; + } + + // Main workspace gets full Hades access + $this->provisionPackage('main', 'hades'); + + // Service workspaces get analytics, social, trust, notify for tracking & upsell + $serviceWorkspaces = ['social', 'analytics', 'trust', 'notify', 'lthn']; + $marketingServices = [ + 'core-srv-analytics-access', + 'core-srv-social-access', + 'core-srv-trust-access', + 'core-srv-notify-access', + ]; + + foreach ($serviceWorkspaces as $workspace) { + foreach ($marketingServices as $package) { + $this->provisionPackage($workspace, $package); + } + } + } + + /** + * Provision a package to a workspace. + */ + protected function provisionPackage(string $workspaceSlug, string $packageCode): void + { + $package = Package::where('code', $packageCode)->first(); + if (! $package) { + return; + } + + $workspace = Workspace::where('slug', $workspaceSlug)->first(); + if (! $workspace) { + return; + } + + WorkspacePackage::updateOrCreate( + [ + 'workspace_id' => $workspace->id, + 'package_id' => $package->id, + ], + [ + 'status' => WorkspacePackage::STATUS_ACTIVE, + 'starts_at' => now(), + 'expires_at' => null, + ] + ); + } +} diff --git a/src/Enums/UserTier.php b/src/Enums/UserTier.php new file mode 100644 index 0000000..ba7b38f --- /dev/null +++ b/src/Enums/UserTier.php @@ -0,0 +1,81 @@ + 'Free', + self::APOLLO => 'Apollo', + self::HADES => 'Hades', + }; + } + + public function color(): string + { + return match ($this) { + self::FREE => 'gray', + self::APOLLO => 'blue', + self::HADES => 'violet', + }; + } + + public function icon(): string + { + return match ($this) { + self::FREE => 'user', + self::APOLLO => 'sun', + self::HADES => 'crown', + }; + } + + public function maxWorkspaces(): int + { + return match ($this) { + self::FREE => 1, + self::APOLLO => 5, + self::HADES => -1, // Unlimited + }; + } + + public function features(): array + { + return match ($this) { + self::FREE => [ + 'basic_content_editing', + 'single_workspace', + ], + self::APOLLO => [ + 'basic_content_editing', + 'advanced_content_editing', + 'multiple_workspaces', + 'analytics_basic', + 'social_scheduling', + ], + self::HADES => [ + 'basic_content_editing', + 'advanced_content_editing', + 'multiple_workspaces', + 'unlimited_workspaces', + 'analytics_basic', + 'analytics_advanced', + 'social_scheduling', + 'social_automation', + 'api_access', + 'priority_support', + 'white_label', + ], + }; + } + + public function hasFeature(string $feature): bool + { + return in_array($feature, $this->features()); + } +} diff --git a/src/Enums/WebhookDeliveryStatus.php b/src/Enums/WebhookDeliveryStatus.php new file mode 100644 index 0000000..d3fef18 --- /dev/null +++ b/src/Enums/WebhookDeliveryStatus.php @@ -0,0 +1,12 @@ + $this->workspace->id, + 'workspace_name' => $this->workspace->name, + 'workspace_slug' => $this->workspace->slug, + 'boost' => [ + 'id' => $this->boost->id, + 'feature_code' => $this->boost->feature_code, + 'feature_name' => $this->feature?->name ?? ucwords(str_replace(['.', '_', '-'], ' ', $this->boost->feature_code)), + 'boost_type' => $this->boost->boost_type, + 'limit_value' => $this->boost->limit_value, + 'duration_type' => $this->boost->duration_type, + 'starts_at' => $this->boost->starts_at?->toIso8601String(), + 'expires_at' => $this->boost->expires_at?->toIso8601String(), + ], + ]; + } + + public function message(): string + { + $featureName = $this->feature?->name ?? $this->boost->feature_code; + + return "Boost activated: {$featureName} for workspace {$this->workspace->name}"; + } +} diff --git a/src/Events/Webhook/BoostExpiredEvent.php b/src/Events/Webhook/BoostExpiredEvent.php new file mode 100644 index 0000000..d1733b4 --- /dev/null +++ b/src/Events/Webhook/BoostExpiredEvent.php @@ -0,0 +1,58 @@ + $this->workspace->id, + 'workspace_name' => $this->workspace->name, + 'workspace_slug' => $this->workspace->slug, + 'boost' => [ + 'id' => $this->boost->id, + 'feature_code' => $this->boost->feature_code, + 'feature_name' => $this->feature?->name ?? ucwords(str_replace(['.', '_', '-'], ' ', $this->boost->feature_code)), + 'boost_type' => $this->boost->boost_type, + 'limit_value' => $this->boost->limit_value, + 'consumed_quantity' => $this->boost->consumed_quantity, + 'duration_type' => $this->boost->duration_type, + 'expired_at' => $this->boost->expires_at?->toIso8601String() ?? now()->toIso8601String(), + ], + ]; + } + + public function message(): string + { + $featureName = $this->feature?->name ?? $this->boost->feature_code; + + return "Boost expired: {$featureName} for workspace {$this->workspace->name}"; + } +} diff --git a/src/Events/Webhook/LimitReachedEvent.php b/src/Events/Webhook/LimitReachedEvent.php new file mode 100644 index 0000000..dc25e8f --- /dev/null +++ b/src/Events/Webhook/LimitReachedEvent.php @@ -0,0 +1,52 @@ + $this->workspace->id, + 'workspace_name' => $this->workspace->name, + 'workspace_slug' => $this->workspace->slug, + 'feature_code' => $this->feature->code, + 'feature_name' => $this->feature->name, + 'used' => $this->used, + 'limit' => $this->limit, + 'percentage' => 100, + 'remaining' => 0, + ]; + } + + public function message(): string + { + return "Limit reached: {$this->feature->name} at 100% ({$this->used}/{$this->limit}) for workspace {$this->workspace->name}"; + } +} diff --git a/src/Events/Webhook/LimitWarningEvent.php b/src/Events/Webhook/LimitWarningEvent.php new file mode 100644 index 0000000..ddfdd0e --- /dev/null +++ b/src/Events/Webhook/LimitWarningEvent.php @@ -0,0 +1,56 @@ + $this->workspace->id, + 'workspace_name' => $this->workspace->name, + 'workspace_slug' => $this->workspace->slug, + 'feature_code' => $this->feature->code, + 'feature_name' => $this->feature->name, + 'used' => $this->used, + 'limit' => $this->limit, + 'percentage' => round(($this->used / $this->limit) * 100), + 'remaining' => max(0, $this->limit - $this->used), + 'threshold' => $this->threshold, + ]; + } + + public function message(): string + { + $percentage = round(($this->used / $this->limit) * 100); + + return "Usage warning: {$this->feature->name} at {$percentage}% ({$this->used}/{$this->limit}) for workspace {$this->workspace->name}"; + } +} diff --git a/src/Events/Webhook/PackageChangedEvent.php b/src/Events/Webhook/PackageChangedEvent.php new file mode 100644 index 0000000..3fbbc96 --- /dev/null +++ b/src/Events/Webhook/PackageChangedEvent.php @@ -0,0 +1,67 @@ + $this->workspace->id, + 'workspace_name' => $this->workspace->name, + 'workspace_slug' => $this->workspace->slug, + 'change_type' => $this->changeType, + 'previous_package' => $this->previousPackage ? [ + 'id' => $this->previousPackage->id, + 'code' => $this->previousPackage->code, + 'name' => $this->previousPackage->name, + ] : null, + 'new_package' => [ + 'id' => $this->newPackage->id, + 'code' => $this->newPackage->code, + 'name' => $this->newPackage->name, + ], + ]; + } + + public function message(): string + { + if ($this->changeType === 'added') { + return "Package added: {$this->newPackage->name} assigned to workspace {$this->workspace->name}"; + } + + if ($this->changeType === 'removed') { + return "Package removed from workspace {$this->workspace->name}"; + } + + $from = $this->previousPackage?->name ?? 'none'; + + return "Package changed: {$from} to {$this->newPackage->name} for workspace {$this->workspace->name}"; + } +} diff --git a/src/Exceptions/EntitlementException.php b/src/Exceptions/EntitlementException.php new file mode 100644 index 0000000..4d01d67 --- /dev/null +++ b/src/Exceptions/EntitlementException.php @@ -0,0 +1,46 @@ +featureCode; + } + + /** + * Render the exception as an HTTP response. + */ + public function render($request) + { + if ($request->expectsJson()) { + return response()->json([ + 'message' => $this->getMessage(), + 'feature_code' => $this->featureCode, + ], $this->getCode()); + } + + return redirect()->back() + ->with('error', $this->getMessage()); + } +} diff --git a/src/Exceptions/MissingWorkspaceContextException.php b/src/Exceptions/MissingWorkspaceContextException.php new file mode 100644 index 0000000..7fe077b --- /dev/null +++ b/src/Exceptions/MissingWorkspaceContextException.php @@ -0,0 +1,133 @@ +withoutGlobalScope(WorkspaceScope::class) if intentionally querying across workspaces.", + operation: 'scope', + model: $model + ); + } + + /** + * Create exception for middleware. + */ + public static function forMiddleware(): self + { + return new self( + message: 'This route requires workspace context. Ensure you are accessing through a valid workspace subdomain or have a workspace session.', + operation: 'middleware' + ); + } + + /** + * Get the operation that failed. + */ + public function getOperation(): ?string + { + return $this->operation; + } + + /** + * Get the model class that was involved. + */ + public function getModel(): ?string + { + return $this->model; + } + + /** + * Render the exception as an HTTP response. + */ + public function render(Request $request): Response + { + if ($request->expectsJson()) { + return response()->json([ + 'message' => $this->getMessage(), + 'error' => 'missing_workspace_context', + 'operation' => $this->operation, + 'model' => $this->model, + ], $this->getCode()); + } + + // For web requests, show a user-friendly error page + if (view()->exists('errors.workspace-required')) { + return response()->view('errors.workspace-required', [ + 'message' => $this->getMessage(), + ], $this->getCode()); + } + + return response($this->getMessage(), $this->getCode()); + } + + /** + * Report the exception (for logging/monitoring). + */ + public function report(): bool + { + // Log this as a potential security issue - workspace context was missing + // where it should have been present + logger()->warning('Missing workspace context', [ + 'operation' => $this->operation, + 'model' => $this->model, + 'url' => request()->url(), + 'user_id' => auth()->id(), + ]); + + // Return true to indicate we've handled reporting + return true; + } +} diff --git a/src/Features/ApolloTier.php b/src/Features/ApolloTier.php new file mode 100644 index 0000000..da3a675 --- /dev/null +++ b/src/Features/ApolloTier.php @@ -0,0 +1,79 @@ +checkWorkspaceEntitlement($scope); + } + + if ($scope instanceof User) { + // Check user's owner workspace + $workspace = $scope->ownedWorkspaces()->first(); + if ($workspace && $this->checkWorkspaceEntitlement($workspace)) { + return true; + } + + // Legacy fallback: check user tier + return $this->checkUserTier($scope); + } + + return false; + } + + /** + * Check if workspace has Apollo or Hades tier entitlement. + */ + protected function checkWorkspaceEntitlement(Workspace $workspace): bool + { + // Apollo is active if workspace has Apollo OR Hades tier + $apolloResult = $this->entitlements->can($workspace, 'tier.apollo'); + $hadesResult = $this->entitlements->can($workspace, 'tier.hades'); + + return $apolloResult->isAllowed() || $hadesResult->isAllowed(); + } + + /** + * Legacy fallback: check user's tier attribute. + */ + protected function checkUserTier(mixed $scope): bool + { + if (method_exists($scope, 'getTier')) { + $tier = $scope->getTier(); + + return $tier === UserTier::APOLLO || $tier === UserTier::HADES; + } + + if (isset($scope->tier)) { + $tier = $scope->tier; + if (is_string($tier)) { + return in_array($tier, [UserTier::APOLLO->value, UserTier::HADES->value]); + } + + return $tier === UserTier::APOLLO || $tier === UserTier::HADES; + } + + return false; + } +} diff --git a/src/Features/BetaFeatures.php b/src/Features/BetaFeatures.php new file mode 100644 index 0000000..892fc62 --- /dev/null +++ b/src/Features/BetaFeatures.php @@ -0,0 +1,42 @@ +choose(); // 10% rollout + } +} diff --git a/src/Features/HadesTier.php b/src/Features/HadesTier.php new file mode 100644 index 0000000..75a5788 --- /dev/null +++ b/src/Features/HadesTier.php @@ -0,0 +1,70 @@ +checkWorkspaceEntitlement($scope); + } + + if ($scope instanceof User) { + // Check user's owner workspace + $workspace = $scope->ownedWorkspaces()->first(); + if ($workspace && $this->checkWorkspaceEntitlement($workspace)) { + return true; + } + + // Legacy fallback: check user tier + return $this->checkUserTier($scope); + } + + return false; + } + + /** + * Check if workspace has Hades tier entitlement. + */ + protected function checkWorkspaceEntitlement(Workspace $workspace): bool + { + $result = $this->entitlements->can($workspace, 'tier.hades'); + + return $result->isAllowed(); + } + + /** + * Legacy fallback: check user's tier attribute. + */ + protected function checkUserTier(mixed $scope): bool + { + if (method_exists($scope, 'getTier')) { + return $scope->getTier() === UserTier::HADES; + } + + if (isset($scope->tier)) { + return $scope->tier === UserTier::HADES->value || $scope->tier === UserTier::HADES; + } + + return false; + } +} diff --git a/src/Features/UnlimitedWorkspaces.php b/src/Features/UnlimitedWorkspaces.php new file mode 100644 index 0000000..4d01e69 --- /dev/null +++ b/src/Features/UnlimitedWorkspaces.php @@ -0,0 +1,75 @@ +checkWorkspaceEntitlement($scope); + } + + if ($scope instanceof User) { + // Check user's owner workspace + $workspace = $scope->ownedWorkspaces()->first(); + if ($workspace && $this->checkWorkspaceEntitlement($workspace)) { + return true; + } + + // Legacy fallback: check user tier + return $this->checkUserTier($scope); + } + + return false; + } + + /** + * Check if workspace has Hades tier entitlement (unlimited workspaces). + */ + protected function checkWorkspaceEntitlement(Workspace $workspace): bool + { + $result = $this->entitlements->can($workspace, 'tier.hades'); + + return $result->isAllowed(); + } + + /** + * Legacy fallback: check user's tier attribute. + */ + protected function checkUserTier(mixed $scope): bool + { + if (method_exists($scope, 'getTier')) { + return $scope->getTier() === UserTier::HADES; + } + + if (isset($scope->tier)) { + $tier = $scope->tier; + if (is_string($tier)) { + return $tier === UserTier::HADES->value; + } + + return $tier === UserTier::HADES; + } + + return false; + } +} diff --git a/src/Jobs/ComputeUserStats.php b/src/Jobs/ComputeUserStats.php new file mode 100644 index 0000000..b315eb0 --- /dev/null +++ b/src/Jobs/ComputeUserStats.php @@ -0,0 +1,43 @@ +userId); + + if (! $user) { + return; + } + + $statsService->computeStats($user); + } +} diff --git a/src/Jobs/DispatchEntitlementWebhook.php b/src/Jobs/DispatchEntitlementWebhook.php new file mode 100644 index 0000000..9a64c91 --- /dev/null +++ b/src/Jobs/DispatchEntitlementWebhook.php @@ -0,0 +1,188 @@ + + */ + public array $backoff = [60, 300, 900]; // 1min, 5min, 15min + + /** + * Create a new job instance. + */ + public function __construct( + public int $webhookId, + public string $eventName, + public array $eventPayload + ) { + $this->onQueue('webhooks'); + } + + /** + * Execute the job. + */ + public function handle(): void + { + $webhook = EntitlementWebhook::find($this->webhookId); + + if (! $webhook) { + Log::warning('Entitlement webhook not found', ['webhook_id' => $this->webhookId]); + + return; + } + + // Skip if webhook is inactive (circuit breaker may have triggered) + if (! $webhook->isActive()) { + Log::info('Entitlement webhook is inactive, skipping', [ + 'webhook_id' => $this->webhookId, + 'event' => $this->eventName, + ]); + + return; + } + + $data = [ + 'event' => $this->eventName, + 'data' => $this->eventPayload, + 'timestamp' => now()->toIso8601String(), + ]; + + try { + $headers = [ + 'Content-Type' => 'application/json', + 'X-Request-Source' => config('app.name'), + 'User-Agent' => config('app.name').' Entitlement Webhook', + ]; + + if ($webhook->secret) { + $headers['X-Signature'] = hash_hmac('sha256', json_encode($data), $webhook->secret); + } + + $response = Http::withHeaders($headers) + ->timeout(10) + ->post($webhook->url, $data); + + $status = match ($response->status()) { + 200, 201, 202, 204 => WebhookDeliveryStatus::SUCCESS, + default => WebhookDeliveryStatus::FAILED, + }; + + // Create delivery record + $webhook->deliveries()->create([ + 'uuid' => Str::uuid(), + 'event' => $this->eventName, + 'attempts' => $this->attempts(), + 'status' => $status, + 'http_status' => $response->status(), + 'payload' => $data, + 'response' => $response->json() ?: ['body' => substr($response->body(), 0, 1000)], + 'created_at' => now(), + ]); + + if ($status === WebhookDeliveryStatus::SUCCESS) { + $webhook->resetFailureCount(); + Log::info('Entitlement webhook delivered successfully', [ + 'webhook_id' => $webhook->id, + 'event' => $this->eventName, + 'http_status' => $response->status(), + ]); + } else { + $webhook->incrementFailureCount(); + $webhook->updateLastDeliveryStatus($status); + + Log::warning('Entitlement webhook delivery failed', [ + 'webhook_id' => $webhook->id, + 'event' => $this->eventName, + 'http_status' => $response->status(), + 'response' => substr($response->body(), 0, 500), + ]); + + // Throw exception to trigger retry + throw new \RuntimeException("Webhook returned {$response->status()}"); + } + + $webhook->updateLastDeliveryStatus($status); + } catch (\Exception $e) { + $webhook->incrementFailureCount(); + $webhook->updateLastDeliveryStatus(WebhookDeliveryStatus::FAILED); + + // Create failure delivery record + $webhook->deliveries()->create([ + 'uuid' => Str::uuid(), + 'event' => $this->eventName, + 'attempts' => $this->attempts(), + 'status' => WebhookDeliveryStatus::FAILED, + 'payload' => $data, + 'response' => ['error' => $e->getMessage()], + 'created_at' => now(), + ]); + + Log::error('Entitlement webhook dispatch exception', [ + 'webhook_id' => $webhook->id, + 'event' => $this->eventName, + 'error' => $e->getMessage(), + 'attempt' => $this->attempts(), + ]); + + throw $e; + } + } + + /** + * Handle job failure after all retries exhausted. + */ + public function failed(\Throwable $exception): void + { + $webhook = EntitlementWebhook::find($this->webhookId); + + Log::error('Entitlement webhook job failed permanently', [ + 'webhook_id' => $this->webhookId, + 'event' => $this->eventName, + 'error' => $exception->getMessage(), + 'circuit_broken' => $webhook?->isCircuitBroken() ?? false, + ]); + } + + /** + * Get the tags that should be assigned to the job. + * + * @return array + */ + public function tags(): array + { + return [ + 'entitlement-webhook', + "webhook:{$this->webhookId}", + "event:{$this->eventName}", + ]; + } +} diff --git a/src/Jobs/ProcessAccountDeletion.php b/src/Jobs/ProcessAccountDeletion.php new file mode 100644 index 0000000..1439795 --- /dev/null +++ b/src/Jobs/ProcessAccountDeletion.php @@ -0,0 +1,130 @@ +deletionRequest->id); + + if (! $request) { + Log::info('Skipping account deletion - request no longer exists', [ + 'deletion_request_id' => $this->deletionRequest->id, + ]); + + return; + } + + // Verify the request is still valid for deletion + if (! $request->isActive()) { + Log::info('Skipping account deletion - request no longer active', [ + 'deletion_request_id' => $request->id, + ]); + + return; + } + + $user = $request->user; + + if (! $user) { + Log::warning('User not found for deletion request', [ + 'deletion_request_id' => $request->id, + ]); + $request->complete(); + + return; + } + + // Update local reference + $this->deletionRequest = $request; + + $userId = $user->id; + + DB::transaction(function () use ($user) { + // Mark request as completed + $this->deletionRequest->complete(); + + // Delete all workspaces owned by the user + if (method_exists($user, 'ownedWorkspaces')) { + $user->ownedWorkspaces()->each(function ($workspace) { + $workspace->delete(); + }); + } + + // Hard delete user account + $user->forceDelete(); + }); + + Log::info('Account deleted successfully', [ + 'user_id' => $userId, + 'deletion_request_id' => $this->deletionRequest->id, + 'via' => 'job', + ]); + } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + Log::error('Failed to process account deletion', [ + 'deletion_request_id' => $this->deletionRequest->id, + 'error' => $exception->getMessage(), + ]); + } + + /** + * Get the tags that should be assigned to the job. + * + * @return array + */ + public function tags(): array + { + return [ + 'account-deletion', + 'user:'.$this->deletionRequest->user_id, + ]; + } +} diff --git a/src/Lang/en_GB/tenant.php b/src/Lang/en_GB/tenant.php new file mode 100644 index 0000000..82b6a57 --- /dev/null +++ b/src/Lang/en_GB/tenant.php @@ -0,0 +1,567 @@ + [ + 'welcome' => 'Welcome', + 'powered_by' => 'Powered by :name\'s creator toolkit. Manage, publish, and grow your online presence.', + 'manage_content' => 'Manage Content', + 'get_early_access' => 'Get early access', + 'view_content' => 'View Content', + 'latest_posts' => 'Latest Posts', + 'pages' => 'Pages', + 'read_more' => 'Read more', + 'untitled' => 'Untitled', + 'no_content' => [ + 'title' => 'No content yet', + 'message' => 'This workspace doesn\'t have any published content.', + ], + 'create_content' => 'Create Content', + 'part_of_toolkit' => 'Part of the :name Toolkit', + 'toolkit_description' => 'Access all creator services from one unified platform', + ], + + /* + |-------------------------------------------------------------------------- + | Account Deletion + |-------------------------------------------------------------------------- + */ + 'deletion' => [ + 'invalid' => [ + 'title' => 'Link Invalid or Expired', + 'message' => 'This deletion link is no longer valid. It may have been cancelled or already used.', + ], + 'verify' => [ + 'title' => 'Verify Your Identity', + 'description' => 'Enter your password to confirm immediate account deletion for :name', + 'password_label' => 'Password', + 'password_placeholder' => 'Enter your password', + 'submit' => 'Verify & Continue', + 'changed_mind' => 'Changed your mind?', + 'cancel_link' => 'Cancel deletion', + ], + 'confirm' => [ + 'title' => 'Final Confirmation', + 'warning' => 'This action is permanent and irreversible.', + 'will_delete' => 'The following will be permanently deleted:', + 'items' => [ + 'profile' => 'Your profile and personal data', + 'workspaces' => 'All workspaces you own', + 'content' => 'All content, media, and settings', + 'social' => 'Social connections and scheduled posts', + ], + 'cancel' => 'Cancel', + 'delete_forever' => 'Delete Forever', + ], + 'deleting' => [ + 'title' => 'Deleting Account', + 'messages' => [ + 'social' => 'Disconnecting social accounts...', + 'posts' => 'Removing scheduled posts...', + 'media' => 'Deleting media files...', + 'workspaces' => 'Removing workspaces...', + 'personal' => 'Erasing personal data...', + 'final' => 'Finalizing deletion...', + ], + ], + 'goodbye' => [ + 'title' => 'F.I.N.', + 'deleted' => 'Your account has been deleted.', + 'thanks' => 'Thank you for being part of our journey.', + ], + 'cancelled' => [ + 'title' => 'Deletion Cancelled', + 'message' => 'Your account deletion has been cancelled. Your account is safe and will remain active.', + 'go_to_profile' => 'Go to Profile', + ], + 'cancel_invalid' => [ + 'title' => 'Link Invalid', + 'message' => 'This cancellation link is no longer valid. The deletion may have already been cancelled or completed.', + ], + 'processing' => 'Processing...', + 'return_home' => 'Return Home', + ], + + /* + |-------------------------------------------------------------------------- + | Admin - Workspace Manager + |-------------------------------------------------------------------------- + */ + 'admin' => [ + 'title' => 'Workspace Manager', + 'subtitle' => 'Manage workspaces and transfer resources', + 'hades_only' => 'Hades Only', + 'stats' => [ + 'total' => 'Total Workspaces', + 'active' => 'Active', + 'inactive' => 'Inactive', + ], + 'search_placeholder' => 'Search workspaces by name or slug...', + 'table' => [ + 'workspace' => 'Workspace', + 'owner' => 'Owner', + 'bio' => 'Bio', + 'social' => 'Social', + 'analytics' => 'Analytics', + 'trust' => 'Trust', + 'notify' => 'Notify', + 'commerce' => 'Commerce', + 'status' => 'Status', + 'actions' => 'Actions', + 'no_owner' => 'No owner', + 'active' => 'Active', + 'inactive' => 'Inactive', + 'empty' => 'No workspaces found matching your criteria.', + ], + 'actions' => [ + 'view_details' => 'View details', + 'edit' => 'Edit workspace', + 'change_owner' => 'Change owner', + 'transfer' => 'Transfer resources', + 'delete' => 'Delete workspace', + 'provision' => 'Provision new', + ], + 'confirm_delete' => 'Are you sure you want to delete this workspace? This cannot be undone.', + 'edit_modal' => [ + 'title' => 'Edit Workspace', + 'name' => 'Name', + 'name_placeholder' => 'Workspace name', + 'slug' => 'Slug', + 'slug_placeholder' => 'workspace-slug', + 'active' => 'Active', + 'cancel' => 'Cancel', + 'save' => 'Save Changes', + ], + 'transfer_modal' => [ + 'title' => 'Transfer Resources', + 'source' => 'Source', + 'target_workspace' => 'Target Workspace', + 'select_target' => 'Select target workspace...', + 'resources_label' => 'Resources to Transfer', + 'warning' => 'Warning: This will move all selected resource types from the source workspace to the target workspace. This action cannot be undone.', + 'cancel' => 'Cancel', + 'transfer' => 'Transfer Resources', + ], + 'owner_modal' => [ + 'title' => 'Change Workspace Owner', + 'workspace' => 'Workspace', + 'new_owner' => 'New Owner', + 'select_owner' => 'Select new owner...', + 'warning' => 'The current owner will be demoted to a member. If the new owner is not already a member, they will be added to the workspace.', + 'cancel' => 'Cancel', + 'change' => 'Change Owner', + ], + 'resources_modal' => [ + 'in' => 'in', + 'select_all' => 'Select All', + 'deselect_all' => 'Deselect All', + 'selected' => ':count selected', + 'no_resources' => 'No resources found.', + 'transfer_selected' => 'Transfer Selected', + 'select_workspace' => 'Select workspace...', + 'transfer_items' => 'Transfer :count Item|Transfer :count Items', + 'close' => 'Close', + ], + 'provision_modal' => [ + 'create' => 'Create :type', + 'workspace' => 'Workspace', + 'name' => 'Name', + 'name_placeholder' => 'Enter name...', + 'slug' => 'Slug', + 'slug_placeholder' => 'my-page', + 'url' => 'URL', + 'url_placeholder' => 'https://example.com', + 'cancel' => 'Cancel', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Usage Alerts + |-------------------------------------------------------------------------- + */ + 'usage_alerts' => [ + 'threshold' => [ + 'warning' => 'Warning', + 'critical' => 'Critical', + 'limit_reached' => 'Limit Reached', + ], + 'status' => [ + 'ok' => 'OK', + 'approaching' => 'Approaching Limit', + 'at_limit' => 'At Limit', + ], + 'labels' => [ + 'used' => 'Used', + 'limit' => 'Limit', + 'remaining' => 'Remaining', + 'percentage' => 'Usage', + 'feature' => 'Feature', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Emails + |-------------------------------------------------------------------------- + */ + 'emails' => [ + 'usage_alert' => [ + 'warning' => [ + 'subject' => ':feature usage at :percentage%', + 'heading' => 'Usage Warning', + 'body' => 'Your **:workspace** workspace is approaching its **:feature** limit.', + 'usage_line' => 'Current usage: :used of :limit (:percentage%)', + 'remaining_line' => 'Remaining: :remaining', + 'action_text' => 'Consider upgrading your plan to ensure uninterrupted service.', + ], + 'critical' => [ + 'subject' => 'Urgent: :feature usage at :percentage%', + 'heading' => 'Critical Usage Alert', + 'body' => 'Your **:workspace** workspace is almost at its **:feature** limit.', + 'usage_line' => 'Current usage: :used of :limit (:percentage%)', + 'remaining_line' => 'Only :remaining remaining', + 'action_text' => 'Upgrade now to avoid any service interruptions.', + ], + 'limit_reached' => [ + 'subject' => ':feature limit reached', + 'heading' => 'Limit Reached', + 'body' => 'Your **:workspace** workspace has reached its **:feature** limit.', + 'usage_line' => 'Usage: :used of :limit (100%)', + 'options_heading' => 'You will not be able to use this feature until:', + 'options' => [ + 'upgrade' => 'You upgrade to a higher plan', + 'reset' => 'Your usage resets (if applicable)', + 'reduce' => 'You reduce your current usage', + ], + ], + 'view_usage' => 'View Usage', + 'upgrade_plan' => 'Upgrade Plan', + 'help_text' => 'If you have questions about your plan, please contact our support team.', + ], + 'deletion_requested' => [ + 'subject' => 'Account Deletion Scheduled', + 'greeting' => 'Hi :name,', + 'scheduled' => 'Your :app account has been scheduled for permanent deletion.', + 'auto_delete' => 'Your account will be automatically deleted on :date (in :days days).', + 'will_delete' => 'What will be deleted:', + 'items' => [ + 'profile' => 'Your profile and personal information', + 'workspaces' => 'All workspaces you own', + 'content' => 'All content, media, and settings', + 'social' => 'Social media connections and scheduled posts', + ], + 'delete_now' => 'Want to delete immediately?', + 'delete_now_description' => 'Click the button below to delete your account right now:', + 'delete_button' => 'Delete Now', + 'changed_mind' => 'Changed your mind?', + 'changed_mind_description' => 'Click below to cancel the deletion and keep your account:', + 'cancel_button' => 'Cancel Deletion', + 'not_requested' => 'Did not request this?', + 'not_requested_description' => 'If you did not request account deletion, click the cancel button above immediately and change your password.', + ], + 'boost_expired' => [ + 'subject_single' => ':feature boost expired - :workspace', + 'subject_multiple' => ':count boosts expired - :workspace', + 'body_single' => 'A boost for **:feature** has expired in your **:workspace** workspace.', + 'body_multiple' => 'The following boosts have expired in your **:workspace** workspace:', + 'cycle_bound_note' => 'This was a cycle-bound boost that ended with your billing period.', + 'action_text' => 'You can purchase additional boosts or upgrade your plan to restore this capacity.', + 'boost_types' => [ + 'unlimited' => 'Unlimited access', + 'enable' => 'Feature access', + 'add_limit' => '+:total capacity (:consumed used)', + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Billing Cycles + |-------------------------------------------------------------------------- + */ + 'billing' => [ + 'cycle_reset' => 'Your billing cycle has been reset.', + 'boosts_expired' => ':count boost(s) have expired.', + 'usage_reset' => 'Usage counters have been reset for the new billing period.', + ], + + /* + |-------------------------------------------------------------------------- + | Common + |-------------------------------------------------------------------------- + */ + 'common' => [ + 'na' => 'N/A', + 'none' => 'None', + 'unknown' => 'Unknown', + ], + + /* + |-------------------------------------------------------------------------- + | Errors + |-------------------------------------------------------------------------- + */ + 'errors' => [ + 'hades_required' => 'Hades tier required for this feature.', + 'unauthenticated' => 'You must be logged in to access this resource.', + 'no_workspace' => 'No workspace context available.', + 'insufficient_permissions' => 'You do not have permission to perform this action.', + ], + + /* + |-------------------------------------------------------------------------- + | Admin - Team Manager + |-------------------------------------------------------------------------- + */ + 'admin' => [ + // ... existing admin translations will be merged ... + + 'team_manager' => [ + 'title' => 'Workspace Teams', + 'subtitle' => 'Manage teams and role-based permissions for workspaces', + + 'stats' => [ + 'total_teams' => 'Total Teams', + 'total_members' => 'Total Members', + 'members_assigned' => 'Assigned to Teams', + ], + + 'search' => [ + 'placeholder' => 'Search teams by name...', + ], + + 'filter' => [ + 'all_workspaces' => 'All Workspaces', + ], + + 'columns' => [ + 'team' => 'Team', + 'workspace' => 'Workspace', + 'members' => 'Members', + 'permissions' => 'Permissions', + 'actions' => 'Actions', + ], + + 'labels' => [ + 'permissions' => 'permissions', + ], + + 'badges' => [ + 'system' => 'System', + 'default' => 'Default', + ], + + 'actions' => [ + 'create_team' => 'Create Team', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'view_members' => 'View Members', + 'seed_defaults' => 'Seed Defaults', + 'migrate_members' => 'Migrate Members', + ], + + 'confirm' => [ + 'delete_team' => 'Are you sure you want to delete this team? Members will be unassigned.', + ], + + 'empty_state' => [ + 'title' => 'No teams found', + 'description' => 'Create teams to organise members and control permissions in your workspaces.', + ], + + 'modal' => [ + 'title_create' => 'Create Team', + 'title_edit' => 'Edit Team', + + 'fields' => [ + 'workspace' => 'Workspace', + 'select_workspace' => 'Select workspace...', + 'name' => 'Name', + 'name_placeholder' => 'e.g. Editors', + 'slug' => 'Slug', + 'slug_placeholder' => 'e.g. editors', + 'slug_description' => 'Leave blank to auto-generate from name.', + 'description' => 'Description', + 'colour' => 'Colour', + 'is_default' => 'Default team for new members', + 'permissions' => 'Permissions', + ], + + 'actions' => [ + 'cancel' => 'Cancel', + 'create' => 'Create Team', + 'update' => 'Update Team', + ], + ], + + 'messages' => [ + 'team_created' => 'Team created successfully.', + 'team_updated' => 'Team updated successfully.', + 'team_deleted' => 'Team deleted successfully.', + 'cannot_delete_system' => 'Cannot delete system teams.', + 'cannot_delete_has_members' => 'Cannot delete team with :count assigned member(s). Remove members first.', + 'defaults_seeded' => 'Default teams have been seeded successfully.', + 'members_migrated' => ':count member(s) have been migrated to teams.', + ], + ], + + 'member_manager' => [ + 'title' => 'Workspace Members', + 'subtitle' => 'Manage member team assignments and custom permissions', + + 'stats' => [ + 'total_members' => 'Total Members', + 'with_team' => 'Assigned to Team', + 'with_custom' => 'With Custom Permissions', + ], + + 'search' => [ + 'placeholder' => 'Search members by name or email...', + ], + + 'filter' => [ + 'all_workspaces' => 'All Workspaces', + 'all_teams' => 'All Teams', + ], + + 'columns' => [ + 'member' => 'Member', + 'workspace' => 'Workspace', + 'team' => 'Team', + 'role' => 'Legacy Role', + 'permissions' => 'Custom', + 'actions' => 'Actions', + ], + + 'labels' => [ + 'no_team' => 'No team', + 'inherited' => 'Inherited', + ], + + 'actions' => [ + 'assign_team' => 'Assign to Team', + 'remove_from_team' => 'Remove from Team', + 'custom_permissions' => 'Custom Permissions', + 'clear_permissions' => 'Clear Custom Permissions', + ], + + 'confirm' => [ + 'clear_permissions' => 'Are you sure you want to clear all custom permissions for this member?', + 'bulk_remove_team' => 'Are you sure you want to remove the selected members from their teams?', + 'bulk_clear_permissions' => 'Are you sure you want to clear custom permissions for all selected members?', + ], + + 'bulk' => [ + 'selected' => ':count selected', + 'assign_team' => 'Assign Team', + 'remove_team' => 'Remove Team', + 'clear_permissions' => 'Clear Permissions', + 'clear' => 'Clear', + ], + + 'empty_state' => [ + 'title' => 'No members found', + 'description' => 'No members match your current filter criteria.', + ], + + 'modal' => [ + 'actions' => [ + 'cancel' => 'Cancel', + 'save' => 'Save', + 'assign' => 'Assign', + ], + ], + + 'assign_modal' => [ + 'title' => 'Assign to Team', + 'team' => 'Team', + 'no_team' => 'No team (remove assignment)', + ], + + 'permissions_modal' => [ + 'title' => 'Custom Permissions', + 'team_permissions' => 'Team: :team', + 'description' => 'Custom permissions override the team permissions. Grant additional permissions or revoke specific ones.', + 'grant_label' => 'Grant Additional Permissions', + 'revoke_label' => 'Revoke Permissions', + ], + + 'bulk_assign_modal' => [ + 'title' => 'Bulk Assign Team', + 'description' => 'Assign :count selected member(s) to a team.', + 'team' => 'Team', + 'no_team' => 'No team (remove assignment)', + ], + + 'messages' => [ + 'team_assigned' => 'Member assigned to team successfully.', + 'removed_from_team' => 'Member removed from team successfully.', + 'permissions_updated' => 'Custom permissions updated successfully.', + 'permissions_cleared' => 'Custom permissions cleared successfully.', + 'no_members_selected' => 'No members selected.', + 'invalid_team' => 'Invalid team selected.', + 'bulk_team_assigned' => ':count member(s) assigned to team.', + 'bulk_removed_from_team' => ':count member(s) removed from team.', + 'bulk_permissions_cleared' => 'Custom permissions cleared for :count member(s).', + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Entitlement Webhooks + |-------------------------------------------------------------------------- + */ + 'webhooks' => [ + 'events' => [ + 'limit_warning' => 'Limit Warning', + 'limit_reached' => 'Limit Reached', + 'package_changed' => 'Package Changed', + 'boost_activated' => 'Boost Activated', + 'boost_expired' => 'Boost Expired', + ], + 'messages' => [ + 'created' => 'Webhook created successfully.', + 'updated' => 'Webhook updated successfully.', + 'deleted' => 'Webhook deleted successfully.', + 'test_success' => 'Test webhook sent successfully.', + 'test_failed' => 'Test webhook failed.', + 'secret_regenerated' => 'Secret regenerated successfully.', + 'circuit_reset' => 'Webhook re-enabled and failure count reset.', + 'retry_success' => 'Delivery retried successfully.', + 'retry_failed' => 'Retry failed.', + ], + 'labels' => [ + 'name' => 'Name', + 'url' => 'URL', + 'events' => 'Events', + 'status' => 'Status', + 'active' => 'Active', + 'inactive' => 'Inactive', + 'circuit_broken' => 'Circuit Broken', + 'secret' => 'Secret', + 'max_attempts' => 'Max Retry Attempts', + 'deliveries' => 'Deliveries', + ], + 'descriptions' => [ + 'url' => 'The endpoint that will receive webhook POST requests.', + 'max_attempts' => 'Number of times to retry failed deliveries (1-10).', + 'inactive' => 'Inactive webhooks will not receive any events.', + 'secret' => 'Use this secret to verify webhook signatures. The signature is sent in the X-Signature header and is a HMAC-SHA256 hash of the JSON payload.', + 'save_secret' => 'Save this secret now. It will not be shown again.', + ], + ], +]; diff --git a/src/Listeners/SendWelcomeEmail.php b/src/Listeners/SendWelcomeEmail.php new file mode 100644 index 0000000..325b090 --- /dev/null +++ b/src/Listeners/SendWelcomeEmail.php @@ -0,0 +1,21 @@ +user->notify(new WelcomeNotification); + } +} diff --git a/src/Mail/AccountDeletionRequested.php b/src/Mail/AccountDeletionRequested.php new file mode 100644 index 0000000..0577105 --- /dev/null +++ b/src/Mail/AccountDeletionRequested.php @@ -0,0 +1,62 @@ + $this->deletionRequest->user, + 'confirmationUrl' => $this->deletionRequest->confirmationUrl(), + 'cancelUrl' => $this->deletionRequest->cancelUrl(), + 'expiresAt' => $this->deletionRequest->expires_at, + 'daysRemaining' => $this->deletionRequest->daysRemaining(), + ], + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/src/Middleware/CheckWorkspacePermission.php b/src/Middleware/CheckWorkspacePermission.php new file mode 100644 index 0000000..be217e3 --- /dev/null +++ b/src/Middleware/CheckWorkspacePermission.php @@ -0,0 +1,96 @@ +user(); + + if (! $user) { + abort(403, __('tenant::tenant.errors.unauthenticated')); + } + + // Get current workspace from request or user's default + $workspace = $this->getWorkspace($request); + + if (! $workspace) { + abort(403, __('tenant::tenant.errors.no_workspace')); + } + + // Set up the team service with the workspace context + $this->teamService->forWorkspace($workspace); + + // Check if user has any of the required permissions + if (! $this->teamService->hasAnyPermission($user, $permissions)) { + abort(403, __('tenant::tenant.errors.insufficient_permissions')); + } + + // Store the workspace and member in request for later use + $request->attributes->set('workspace_model', $workspace); + + $member = $this->teamService->getMember($user); + if ($member) { + $request->attributes->set('workspace_member', $member); + } + + return $next($request); + } + + protected function getWorkspace(Request $request): ?Workspace + { + // First try to get from request attributes (already resolved by other middleware) + if ($request->attributes->has('workspace_model')) { + return $request->attributes->get('workspace_model'); + } + + // Try to get from route parameter + $workspaceParam = $request->route('workspace'); + if ($workspaceParam instanceof Workspace) { + return $workspaceParam; + } + + if (is_string($workspaceParam) || is_int($workspaceParam)) { + return Workspace::where('slug', $workspaceParam) + ->orWhere('id', $workspaceParam) + ->first(); + } + + // Try to get from session + $sessionSlug = session('workspace'); + if ($sessionSlug) { + return Workspace::where('slug', $sessionSlug)->first(); + } + + // Fall back to user's default workspace + $user = $request->user(); + if ($user && method_exists($user, 'defaultHostWorkspace')) { + return $user->defaultHostWorkspace(); + } + + return null; + } +} diff --git a/src/Middleware/RequireAdminDomain.php b/src/Middleware/RequireAdminDomain.php new file mode 100644 index 0000000..ceae3c6 --- /dev/null +++ b/src/Middleware/RequireAdminDomain.php @@ -0,0 +1,34 @@ +attributes->get('is_admin_domain', true); + + // Allow access on admin domains or local development + if ($isAdminDomain) { + return $next($request); + } + + // On service subdomains, redirect to the public workspace page + $workspace = $request->attributes->get('workspace', 'main'); + + // Redirect to the public page for this workspace + return redirect()->route('workspace.show', ['workspace' => $workspace]); + } +} diff --git a/src/Middleware/RequireWorkspaceContext.php b/src/Middleware/RequireWorkspaceContext.php new file mode 100644 index 0000000..a3f4c21 --- /dev/null +++ b/src/Middleware/RequireWorkspaceContext.php @@ -0,0 +1,118 @@ +group(function () { + * Route::resource('accounts', AccountController::class); + * }); + * + * Register in Kernel.php: + * 'workspace.required' => \Core\Mod\Tenant\Middleware\RequireWorkspaceContext::class, + */ +class RequireWorkspaceContext +{ + /** + * Handle an incoming request. + * + * @throws MissingWorkspaceContextException When workspace context is missing + */ + public function handle(Request $request, Closure $next, ?string $validateAccess = null): Response + { + // Get current workspace from various sources + $workspace = $this->resolveWorkspace($request); + + if (! $workspace) { + throw MissingWorkspaceContextException::forMiddleware(); + } + + // Optionally validate user has access to the workspace + if ($validateAccess === 'validate' && auth()->check()) { + $this->validateUserAccess($request, $workspace); + } + + // Ensure workspace is set in request attributes for downstream use + if (! $request->attributes->has('workspace_model')) { + $request->attributes->set('workspace_model', $workspace); + } + + return $next($request); + } + + /** + * Resolve workspace from request. + */ + protected function resolveWorkspace(Request $request): ?Workspace + { + // 1. Check if workspace_model is already set (by ResolveWorkspaceFromSubdomain) + if ($request->attributes->has('workspace_model')) { + return $request->attributes->get('workspace_model'); + } + + // 2. Try Workspace::current() which checks multiple sources + $current = Workspace::current(); + if ($current) { + return $current; + } + + // 3. Check request input for workspace_id (API requests) + if ($workspaceId = $request->input('workspace_id')) { + return Workspace::find($workspaceId); + } + + // 4. Check header for workspace context (API requests) + if ($workspaceId = $request->header('X-Workspace-ID')) { + return Workspace::find($workspaceId); + } + + // 5. Check query parameter for workspace (API/webhook requests) + if ($workspaceSlug = $request->query('workspace')) { + return Workspace::where('slug', $workspaceSlug)->first(); + } + + return null; + } + + /** + * Validate that the authenticated user has access to the workspace. + * + * @throws MissingWorkspaceContextException When user doesn't have access + */ + protected function validateUserAccess(Request $request, Workspace $workspace): void + { + $user = auth()->user(); + + // Check if user model has workspaces relationship + if (method_exists($user, 'workspaces') || method_exists($user, 'hostWorkspaces')) { + $workspaces = method_exists($user, 'hostWorkspaces') + ? $user->hostWorkspaces + : $user->workspaces; + + if (! $workspaces->contains('id', $workspace->id)) { + throw new MissingWorkspaceContextException( + message: 'You do not have access to this workspace.', + operation: 'access', + code: 403 + ); + } + } + } +} diff --git a/src/Middleware/ResolveNamespace.php b/src/Middleware/ResolveNamespace.php new file mode 100644 index 0000000..9a8eed9 --- /dev/null +++ b/src/Middleware/ResolveNamespace.php @@ -0,0 +1,59 @@ +query('namespace')) { + $namespace = $this->namespaceService->findByUuid($namespaceUuid); + if ($namespace && $this->namespaceService->canAccess($namespace)) { + // Store in session for subsequent requests + $this->namespaceService->setCurrent($namespace); + $request->attributes->set('current_namespace', $namespace); + + return $next($request); + } + } + + // Try to resolve namespace from header (for API requests) + if ($namespaceUuid = $request->header('X-Namespace')) { + $namespace = $this->namespaceService->findByUuid($namespaceUuid); + if ($namespace && $this->namespaceService->canAccess($namespace)) { + $request->attributes->set('current_namespace', $namespace); + + return $next($request); + } + } + + // Try to resolve from session + $namespace = $this->namespaceService->current(); + if ($namespace) { + $request->attributes->set('current_namespace', $namespace); + } + + return $next($request); + } +} diff --git a/src/Middleware/ResolveWorkspaceFromSubdomain.php b/src/Middleware/ResolveWorkspaceFromSubdomain.php new file mode 100644 index 0000000..9f195ff --- /dev/null +++ b/src/Middleware/ResolveWorkspaceFromSubdomain.php @@ -0,0 +1,142 @@ +getHost(); + $subdomain = $this->extractSubdomain($host); + $workspace = $this->resolveWorkspaceFromSubdomain($subdomain); + + // Store subdomain info in request + $request->attributes->set('subdomain', $subdomain); + $request->attributes->set('is_admin_domain', $this->isAdminDomain($subdomain)); + + if ($workspace) { + // Wrap session operations in try-catch to handle corrupted sessions + try { + $this->workspaceService->setCurrent($workspace); + $request->attributes->set('workspace_data', $this->workspaceService->current()); + } catch (\Throwable) { + // Session write failed - continue with defaults + // ResilientSession middleware will handle the actual error + } + + $request->attributes->set('workspace', $workspace); + + // CRITICAL: Also set the Workspace MODEL instance (not array) + // This enables Workspace::current() and WorkspaceScope to work + try { + $workspaceModel = Workspace::where('slug', $workspace)->first(); + if ($workspaceModel) { + $request->attributes->set('workspace_model', $workspaceModel); + } + } catch (\Throwable) { + // Database query failed - continue without workspace model + } + } + + return $next($request); + } + + /** + * Extract subdomain from hostname. + */ + protected function extractSubdomain(string $host): string + { + $baseDomain = config('app.base_domain', 'host.uk.com'); + + // Handle localhost/dev environments + if (str_contains($host, 'localhost') || str_contains($host, '127.0.0.1') || str_ends_with($host, '.test')) { + return ''; // Treat as main domain for local dev + } + + // Check if this is our base domain + if (! str_ends_with($host, $baseDomain)) { + return ''; + } + + // Extract subdomain + $subdomain = str_replace('.'.$baseDomain, '', $host); + + // Handle bare domain (no subdomain) + if ($subdomain === $host) { + return ''; + } + + return $subdomain; + } + + /** + * Check if subdomain should serve admin panel. + */ + public function isAdminDomain(?string $subdomain): bool + { + return in_array($subdomain ?? '', $this->adminSubdomains, true); + } + + /** + * Resolve workspace slug from subdomain. + */ + protected function resolveWorkspaceFromSubdomain(string $subdomain): ?string + { + // Map subdomains to workspace slugs (must match database Workspace slugs) + $mappings = [ + // Admin/main domain aliases + 'hestia' => 'main', + 'main' => 'main', + 'www' => 'main', + 'hub' => 'main', + '' => 'main', + // Service subdomains - bio is canonical, link is alias + 'bio' => 'bio', + 'link' => 'bio', + 'social' => 'social', + 'analytics' => 'analytics', + 'stats' => 'analytics', + 'trust' => 'trust', + 'proof' => 'trust', + 'notify' => 'notify', + 'push' => 'notify', + ]; + + if (isset($mappings[$subdomain])) { + return $mappings[$subdomain]; + } + + // Check if subdomain matches a workspace slug directly + $workspace = $this->workspaceService->get($subdomain); + if ($workspace) { + return $subdomain; + } + + // Unknown subdomain - could be a user subdomain, default to main + return 'main'; + } +} diff --git a/src/Migrations/0001_01_01_000000_create_tenant_tables.php b/src/Migrations/0001_01_01_000000_create_tenant_tables.php new file mode 100644 index 0000000..8f624c5 --- /dev/null +++ b/src/Migrations/0001_01_01_000000_create_tenant_tables.php @@ -0,0 +1,316 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->string('tier')->default('free'); + $table->timestamp('tier_expires_at')->nullable(); + $table->timestamps(); + }); + + // 2. Password Reset Tokens + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + // 3. Sessions + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + + // 4. Workspaces (the tenant boundary) + Schema::create('workspaces', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('domain')->nullable(); + $table->string('icon')->nullable(); + $table->string('color')->nullable(); + $table->text('description')->nullable(); + $table->string('type')->default('default'); + $table->json('settings')->nullable(); + $table->boolean('is_active')->default(true); + $table->integer('sort_order')->default(0); + + // WP Connector fields + $table->boolean('wp_connector_enabled')->default(false); + $table->string('wp_connector_url')->nullable(); + $table->string('wp_connector_secret')->nullable(); + $table->timestamp('wp_connector_verified_at')->nullable(); + $table->timestamp('wp_connector_last_sync')->nullable(); + $table->json('wp_connector_config')->nullable(); + + // Billing fields + $table->string('stripe_customer_id')->nullable(); + $table->string('btcpay_customer_id')->nullable(); + $table->string('billing_name')->nullable(); + $table->string('billing_email')->nullable(); + $table->string('billing_address_line1')->nullable(); + $table->string('billing_address_line2')->nullable(); + $table->string('billing_city')->nullable(); + $table->string('billing_state')->nullable(); + $table->string('billing_postal_code')->nullable(); + $table->string('billing_country')->nullable(); + $table->string('vat_number')->nullable(); + $table->string('tax_id')->nullable(); + $table->boolean('tax_exempt')->default(false); + + $table->timestamps(); + $table->softDeletes(); + }); + + // 5. User Workspace Pivot + Schema::create('user_workspace', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('role')->default('member'); + $table->boolean('is_default')->default(false); + $table->timestamps(); + + $table->unique(['user_id', 'workspace_id']); + }); + + // 6. Namespaces + Schema::create('namespaces', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('name', 128); + $table->string('slug', 64); + $table->string('description', 512)->nullable(); + $table->string('icon', 64)->default('folder'); + $table->string('color', 16)->default('zinc'); + + // Polymorphic owner (User::class or Workspace::class) + $table->morphs('owner'); + + // Workspace context for billing aggregation + $table->foreignId('workspace_id')->nullable() + ->constrained()->nullOnDelete(); + + $table->json('settings')->nullable(); + $table->boolean('is_default')->default(false); + $table->boolean('is_active')->default(true); + $table->smallInteger('sort_order')->default(0); + + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['owner_type', 'owner_id', 'slug']); + $table->index(['workspace_id', 'is_active']); + $table->index(['owner_type', 'owner_id', 'is_active']); + }); + + // 7. Entitlement Features + Schema::create('entitlement_features', function (Blueprint $table) { + $table->id(); + $table->string('code')->unique(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('category')->nullable(); + $table->enum('type', ['boolean', 'limit', 'unlimited'])->default('boolean'); + $table->enum('reset_type', ['none', 'monthly', 'rolling'])->default('none'); + $table->integer('rolling_window_days')->nullable(); + $table->foreignId('parent_feature_id')->nullable() + ->constrained('entitlement_features')->nullOnDelete(); + $table->integer('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['category', 'sort_order']); + $table->index('category'); + }); + + // 8. Entitlement Packages + Schema::create('entitlement_packages', function (Blueprint $table) { + $table->id(); + $table->string('code')->unique(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('icon')->nullable(); + $table->string('color')->nullable(); + $table->integer('sort_order')->default(0); + $table->boolean('is_stackable')->default(true); + $table->boolean('is_base_package')->default(false); + $table->boolean('is_active')->default(true); + $table->boolean('is_public')->default(true); + $table->decimal('monthly_price', 10, 2)->nullable(); + $table->decimal('yearly_price', 10, 2)->nullable(); + $table->decimal('setup_fee', 10, 2)->default(0); + $table->unsignedInteger('trial_days')->default(0); + $table->string('stripe_monthly_price_id')->nullable(); + $table->string('stripe_yearly_price_id')->nullable(); + $table->string('btcpay_monthly_price_id')->nullable(); + $table->string('btcpay_yearly_price_id')->nullable(); + $table->string('blesta_package_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('blesta_package_id'); + }); + + // 9. Entitlement Package Features + Schema::create('entitlement_package_features', function (Blueprint $table) { + $table->id(); + $table->foreignId('package_id')->constrained('entitlement_packages')->cascadeOnDelete(); + $table->foreignId('feature_id')->constrained('entitlement_features')->cascadeOnDelete(); + $table->unsignedBigInteger('limit_value')->nullable(); + $table->timestamps(); + + $table->unique(['package_id', 'feature_id']); + }); + + // 10. Entitlement Workspace Packages + Schema::create('entitlement_workspace_packages', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('package_id')->constrained('entitlement_packages')->cascadeOnDelete(); + $table->enum('status', ['active', 'suspended', 'cancelled', 'expired'])->default('active'); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('billing_cycle_anchor')->nullable(); + $table->string('blesta_service_id')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['workspace_id', 'status'], 'ent_ws_pkg_ws_status_idx'); + $table->index(['expires_at', 'status'], 'ent_ws_pkg_expires_status_idx'); + $table->index('blesta_service_id'); + }); + + // 11. Entitlement Namespace Packages + Schema::create('entitlement_namespace_packages', function (Blueprint $table) { + $table->id(); + $table->foreignId('namespace_id')->constrained('namespaces')->cascadeOnDelete(); + $table->foreignId('package_id')->constrained('entitlement_packages')->cascadeOnDelete(); + $table->enum('status', ['active', 'suspended', 'cancelled', 'expired'])->default('active'); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['namespace_id', 'status']); + $table->index(['expires_at', 'status']); + }); + + // 12. Entitlement Boosts + Schema::create('entitlement_boosts', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('feature_code'); + $table->enum('boost_type', ['add_limit', 'enable', 'unlimited'])->default('add_limit'); + $table->enum('duration_type', ['cycle_bound', 'duration', 'permanent'])->default('cycle_bound'); + $table->unsignedBigInteger('limit_value')->nullable(); + $table->unsignedBigInteger('consumed_quantity')->default(0); + $table->enum('status', ['active', 'exhausted', 'expired', 'cancelled'])->default('active'); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->string('blesta_addon_id')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'feature_code', 'status'], 'ent_boosts_ws_feat_status_idx'); + $table->index(['expires_at', 'status'], 'ent_boosts_expires_status_idx'); + $table->index('feature_code'); + $table->index('blesta_addon_id'); + }); + + // 13. Entitlement Usage Records + Schema::create('entitlement_usage_records', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('feature_code'); + $table->unsignedBigInteger('quantity')->default(1); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->json('metadata')->nullable(); + $table->timestamp('recorded_at'); + $table->timestamps(); + + $table->index(['workspace_id', 'feature_code', 'recorded_at'], 'ent_usage_ws_feat_rec_idx'); + $table->index('recorded_at', 'ent_usage_recorded_idx'); + $table->index('feature_code'); + }); + + // 14. Entitlement Logs + Schema::create('entitlement_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('action'); + $table->string('entity_type'); + $table->unsignedBigInteger('entity_id')->nullable(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('source')->nullable(); + $table->json('old_values')->nullable(); + $table->json('new_values')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'action'], 'ent_logs_ws_action_idx'); + $table->index(['entity_type', 'entity_id'], 'ent_logs_entity_idx'); + $table->index('created_at', 'ent_logs_created_idx'); + }); + + // 15. User Two-Factor Auth + Schema::create('user_two_factor_auth', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->text('secret')->nullable(); + $table->json('recovery_codes')->nullable(); + $table->timestamp('confirmed_at')->nullable(); + $table->timestamp('enabled_at')->nullable(); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + Schema::dropIfExists('user_two_factor_auth'); + Schema::dropIfExists('entitlement_logs'); + Schema::dropIfExists('entitlement_usage_records'); + Schema::dropIfExists('entitlement_boosts'); + Schema::dropIfExists('entitlement_namespace_packages'); + Schema::dropIfExists('entitlement_workspace_packages'); + Schema::dropIfExists('entitlement_package_features'); + Schema::dropIfExists('entitlement_packages'); + Schema::dropIfExists('entitlement_features'); + Schema::dropIfExists('namespaces'); + Schema::dropIfExists('user_workspace'); + Schema::dropIfExists('workspaces'); + Schema::dropIfExists('sessions'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('users'); + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/src/Migrations/2026_01_26_000000_create_workspace_invitations_table.php b/src/Migrations/2026_01_26_000000_create_workspace_invitations_table.php new file mode 100644 index 0000000..2429301 --- /dev/null +++ b/src/Migrations/2026_01_26_000000_create_workspace_invitations_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('token', 64)->unique(); + $table->string('role')->default('member'); + $table->foreignId('invited_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('expires_at'); + $table->timestamp('accepted_at')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'email']); + $table->index(['email', 'accepted_at']); + $table->index('expires_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('workspace_invitations'); + } +}; diff --git a/src/Migrations/2026_01_26_120000_create_usage_alert_history_table.php b/src/Migrations/2026_01_26_120000_create_usage_alert_history_table.php new file mode 100644 index 0000000..e9d0aca --- /dev/null +++ b/src/Migrations/2026_01_26_120000_create_usage_alert_history_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('feature_code'); + $table->unsignedTinyInteger('threshold'); // 80, 90, 100 + $table->timestamp('notified_at'); + $table->timestamp('resolved_at')->nullable(); // When usage dropped below threshold + $table->json('metadata')->nullable(); // Snapshot of usage at notification time + $table->timestamps(); + + $table->index(['workspace_id', 'feature_code', 'threshold'], 'usage_alert_ws_feat_thresh_idx'); + $table->index(['workspace_id', 'resolved_at'], 'usage_alert_ws_resolved_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('entitlement_usage_alert_history'); + } +}; diff --git a/src/Migrations/2026_01_26_140000_create_entitlement_webhooks_tables.php b/src/Migrations/2026_01_26_140000_create_entitlement_webhooks_tables.php new file mode 100644 index 0000000..7bb8fdd --- /dev/null +++ b/src/Migrations/2026_01_26_140000_create_entitlement_webhooks_tables.php @@ -0,0 +1,63 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('url', 2048); + $table->text('secret')->nullable(); // Encrypted HMAC secret + $table->json('events'); // Array of subscribed event types + $table->boolean('is_active')->default(true); + $table->unsignedTinyInteger('max_attempts')->default(3); + $table->string('last_delivery_status')->nullable(); // pending, success, failed + $table->timestamp('last_triggered_at')->nullable(); + $table->unsignedInteger('failure_count')->default(0); + $table->json('metadata')->nullable(); // Additional configuration + $table->timestamps(); + + $table->index(['workspace_id', 'is_active'], 'ent_wh_ws_active_idx'); + $table->index('uuid'); + }); + + Schema::create('entitlement_webhook_deliveries', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid'); + $table->foreignId('webhook_id') + ->constrained('entitlement_webhooks') + ->cascadeOnDelete(); + $table->string('event'); // Event name: limit_warning, limit_reached, etc. + $table->unsignedTinyInteger('attempts')->default(1); + $table->string('status'); // pending, success, failed + $table->unsignedSmallInteger('http_status')->nullable(); + $table->timestamp('resend_at')->nullable(); + $table->boolean('resent_manually')->default(false); + $table->json('payload'); + $table->json('response')->nullable(); + $table->timestamp('created_at'); + + $table->index(['webhook_id', 'status'], 'ent_wh_del_wh_status_idx'); + $table->index(['webhook_id', 'created_at'], 'ent_wh_del_wh_created_idx'); + $table->index('uuid'); + }); + } + + public function down(): void + { + Schema::dropIfExists('entitlement_webhook_deliveries'); + Schema::dropIfExists('entitlement_webhooks'); + } +}; diff --git a/src/Migrations/2026_01_26_140000_create_workspace_teams_table.php b/src/Migrations/2026_01_26_140000_create_workspace_teams_table.php new file mode 100644 index 0000000..cf8f09e --- /dev/null +++ b/src/Migrations/2026_01_26_140000_create_workspace_teams_table.php @@ -0,0 +1,59 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('slug'); + $table->text('description')->nullable(); + $table->json('permissions')->nullable(); + $table->boolean('is_default')->default(false); + $table->boolean('is_system')->default(false); + $table->string('colour', 32)->default('zinc'); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['workspace_id', 'slug']); + $table->index(['workspace_id', 'is_default']); + }); + + // 2. Enhance user_workspace pivot table + Schema::table('user_workspace', function (Blueprint $table) { + $table->foreignId('team_id')->nullable() + ->after('role') + ->constrained('workspace_teams') + ->nullOnDelete(); + $table->json('custom_permissions')->nullable()->after('team_id'); + $table->timestamp('joined_at')->nullable()->after('custom_permissions'); + $table->foreignId('invited_by')->nullable() + ->after('joined_at') + ->constrained('users') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('user_workspace', function (Blueprint $table) { + $table->dropForeign(['team_id']); + $table->dropForeign(['invited_by']); + $table->dropColumn(['team_id', 'custom_permissions', 'joined_at', 'invited_by']); + }); + + Schema::dropIfExists('workspace_teams'); + } +}; diff --git a/src/Models/AccountDeletionRequest.php b/src/Models/AccountDeletionRequest.php new file mode 100644 index 0000000..5716742 --- /dev/null +++ b/src/Models/AccountDeletionRequest.php @@ -0,0 +1,160 @@ + 'datetime', + 'confirmed_at' => 'datetime', + 'completed_at' => 'datetime', + 'cancelled_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Create a new deletion request for a user. + * Account WILL be deleted in 7 days unless cancelled. + * Clicking the email link deletes immediately after re-auth. + */ + public static function createForUser(User $user, ?string $reason = null): self + { + // Cancel any existing pending requests + static::where('user_id', $user->id) + ->whereNull('completed_at') + ->whereNull('cancelled_at') + ->delete(); + + return static::create([ + 'user_id' => $user->id, + 'token' => Str::random(64), + 'reason' => $reason, + 'expires_at' => now()->addDays(7), + ]); + } + + /** + * Find a valid request by token (for immediate deletion via email link). + */ + public static function findValidByToken(string $token): ?self + { + return static::where('token', $token) + ->whereNull('completed_at') + ->whereNull('cancelled_at') + ->first(); + } + + /** + * Get all pending requests that should be auto-deleted (past expiry). + */ + public static function pendingAutoDelete() + { + return static::where('expires_at', '<=', now()) + ->whereNull('completed_at') + ->whereNull('cancelled_at'); + } + + /** + * Check if the request is still active (not completed or cancelled). + */ + public function isActive(): bool + { + return is_null($this->completed_at) && is_null($this->cancelled_at); + } + + /** + * Check if the request is pending deletion (scheduled but not executed). + */ + public function isPending(): bool + { + return $this->isActive() && $this->expires_at->isFuture(); + } + + /** + * Check if the request is ready for auto-deletion (past expiry). + */ + public function isReadyForAutoDeletion(): bool + { + return $this->isActive() && $this->expires_at->isPast(); + } + + /** + * Mark the request as confirmed (user clicked email link). + */ + public function confirm(): self + { + $this->update(['confirmed_at' => now()]); + + return $this; + } + + /** + * Mark the request as completed (account deleted). + */ + public function complete(): self + { + $this->update(['completed_at' => now()]); + + return $this; + } + + /** + * Cancel the deletion request. + */ + public function cancel(): self + { + $this->update(['cancelled_at' => now()]); + + return $this; + } + + /** + * Get days remaining until auto-deletion. + */ + public function daysRemaining(): int + { + return max(0, (int) now()->diffInDays($this->expires_at, false)); + } + + /** + * Get hours remaining until auto-deletion. + */ + public function hoursRemaining(): int + { + return max(0, (int) now()->diffInHours($this->expires_at, false)); + } + + /** + * Get the immediate deletion URL (for email). + */ + public function confirmationUrl(): string + { + return route('account.delete.confirm', ['token' => $this->token]); + } + + /** + * Get the cancel URL. + */ + public function cancelUrl(): string + { + return route('account.delete.cancel', ['token' => $this->token]); + } +} diff --git a/src/Models/AgentReferralBonus.php b/src/Models/AgentReferralBonus.php new file mode 100644 index 0000000..0f0af0e --- /dev/null +++ b/src/Models/AgentReferralBonus.php @@ -0,0 +1,110 @@ + 'boolean', + 'last_conversion_at' => 'datetime', + 'total_conversions' => 'integer', + ]; + + /** + * Get or create a bonus record for a provider/model. + */ + public static function getOrCreate(string $provider, ?string $model = null): self + { + return static::firstOrCreate( + ['provider' => $provider, 'model' => $model], + ['next_referral_guaranteed' => false, 'total_conversions' => 0] + ); + } + + /** + * Check if the next referral is guaranteed for a provider/model. + */ + public static function hasGuaranteedReferral(string $provider, ?string $model = null): bool + { + $bonus = static::where('provider', $provider) + ->where('model', $model) + ->first(); + + return $bonus?->next_referral_guaranteed ?? false; + } + + /** + * Grant a guaranteed next referral to a provider/model. + */ + public static function grantGuaranteedReferral(string $provider, ?string $model = null): self + { + $bonus = static::getOrCreate($provider, $model); + + $bonus->update([ + 'next_referral_guaranteed' => true, + 'last_conversion_at' => now(), + 'total_conversions' => $bonus->total_conversions + 1, + ]); + + return $bonus; + } + + /** + * Consume the guaranteed referral for a provider/model. + */ + public static function consumeGuaranteedReferral(string $provider, ?string $model = null): bool + { + $bonus = static::where('provider', $provider) + ->where('model', $model) + ->where('next_referral_guaranteed', true) + ->first(); + + if (! $bonus) { + return false; + } + + $bonus->update(['next_referral_guaranteed' => false]); + + return true; + } + + /** + * Scope to a specific provider. + */ + public function scopeForProvider(Builder $query, string $provider): Builder + { + return $query->where('provider', $provider); + } + + /** + * Scope to records with guaranteed next referral. + */ + public function scopeGuaranteed(Builder $query): Builder + { + return $query->where('next_referral_guaranteed', true); + } + + /** + * Check if this bonus has a guaranteed next referral. + */ + public function hasGuarantee(): bool + { + return $this->next_referral_guaranteed; + } +} diff --git a/src/Models/Boost.php b/src/Models/Boost.php new file mode 100644 index 0000000..9c43e19 --- /dev/null +++ b/src/Models/Boost.php @@ -0,0 +1,220 @@ + 'integer', + 'consumed_quantity' => 'integer', + 'starts_at' => 'datetime', + 'expires_at' => 'datetime', + 'metadata' => 'array', + ]; + + /** + * Boost types. + */ + public const BOOST_TYPE_ADD_LIMIT = 'add_limit'; + + public const BOOST_TYPE_ENABLE = 'enable'; + + public const BOOST_TYPE_UNLIMITED = 'unlimited'; + + /** + * Duration types. + */ + public const DURATION_CYCLE_BOUND = 'cycle_bound'; + + public const DURATION_DURATION = 'duration'; + + public const DURATION_PERMANENT = 'permanent'; + + /** + * Status constants. + */ + public const STATUS_ACTIVE = 'active'; + + public const STATUS_EXHAUSTED = 'exhausted'; + + public const STATUS_EXPIRED = 'expired'; + + public const STATUS_CANCELLED = 'cancelled'; + + /** + * The workspace this boost belongs to. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * The namespace this boost belongs to. + */ + public function namespace(): BelongsTo + { + return $this->belongsTo(Namespace_::class, 'namespace_id'); + } + + /** + * The user this boost belongs to (for user-level boosts like vanity URLs). + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Scope to active boosts. + */ + public function scopeActive($query) + { + return $query->where('status', self::STATUS_ACTIVE); + } + + /** + * Scope to a specific feature. + */ + public function scopeForFeature($query, string $featureCode) + { + return $query->where('feature_code', $featureCode); + } + + /** + * Scope to usable boosts (active and not expired). + */ + public function scopeUsable($query) + { + return $query->where('status', self::STATUS_ACTIVE) + ->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->where(function ($q) { + $q->whereNull('starts_at') + ->orWhere('starts_at', '<=', now()); + }); + } + + /** + * Check if this boost is currently usable. + */ + public function isUsable(): bool + { + if ($this->status !== self::STATUS_ACTIVE) { + return false; + } + + if ($this->starts_at && $this->starts_at->isFuture()) { + return false; + } + + if ($this->expires_at && $this->expires_at->isPast()) { + return false; + } + + return true; + } + + /** + * Get remaining limit for this boost. + */ + public function getRemainingLimit(): ?int + { + if ($this->boost_type === self::BOOST_TYPE_UNLIMITED) { + return null; // Unlimited + } + + if ($this->boost_type === self::BOOST_TYPE_ENABLE) { + return null; // Boolean, no limit + } + + return max(0, $this->limit_value - $this->consumed_quantity); + } + + /** + * Consume some of this boost's limit. + */ + public function consume(int $quantity = 1): bool + { + if (! $this->isUsable()) { + return false; + } + + if ($this->boost_type !== self::BOOST_TYPE_ADD_LIMIT) { + return true; // No consumption for enable/unlimited + } + + $remaining = $this->getRemainingLimit(); + + if ($remaining !== null && $quantity > $remaining) { + return false; + } + + $this->increment('consumed_quantity', $quantity); + + // Check if exhausted + if ($this->getRemainingLimit() === 0) { + $this->update(['status' => self::STATUS_EXHAUSTED]); + } + + return true; + } + + /** + * Check if this boost has remaining capacity. + */ + public function hasCapacity(): bool + { + if ($this->boost_type === self::BOOST_TYPE_UNLIMITED) { + return true; + } + + if ($this->boost_type === self::BOOST_TYPE_ENABLE) { + return true; + } + + return $this->getRemainingLimit() > 0; + } + + /** + * Expire this boost. + */ + public function expire(): void + { + $this->update(['status' => self::STATUS_EXPIRED]); + } + + /** + * Cancel this boost. + */ + public function cancel(): void + { + $this->update(['status' => self::STATUS_CANCELLED]); + } +} diff --git a/src/Models/EntitlementLog.php b/src/Models/EntitlementLog.php new file mode 100644 index 0000000..366df43 --- /dev/null +++ b/src/Models/EntitlementLog.php @@ -0,0 +1,207 @@ + 'array', + 'new_values' => 'array', + 'metadata' => 'array', + ]; + + /** + * Action constants. + */ + public const ACTION_PACKAGE_PROVISIONED = 'package.provisioned'; + + public const ACTION_PACKAGE_SUSPENDED = 'package.suspended'; + + public const ACTION_PACKAGE_CANCELLED = 'package.cancelled'; + + public const ACTION_PACKAGE_REACTIVATED = 'package.reactivated'; + + public const ACTION_PACKAGE_RENEWED = 'package.renewed'; + + public const ACTION_PACKAGE_EXPIRED = 'package.expired'; + + public const ACTION_BOOST_PROVISIONED = 'boost.provisioned'; + + public const ACTION_BOOST_CONSUMED = 'boost.consumed'; + + public const ACTION_BOOST_EXHAUSTED = 'boost.exhausted'; + + public const ACTION_BOOST_EXPIRED = 'boost.expired'; + + public const ACTION_BOOST_CANCELLED = 'boost.cancelled'; + + public const ACTION_USAGE_RECORDED = 'usage.recorded'; + + public const ACTION_USAGE_DENIED = 'usage.denied'; + + public const ACTION_CYCLE_RESET = 'cycle.reset'; + + /** + * Source constants. + */ + public const SOURCE_BLESTA = 'blesta'; + + public const SOURCE_COMMERCE = 'commerce'; + + public const SOURCE_ADMIN = 'admin'; + + public const SOURCE_SYSTEM = 'system'; + + public const SOURCE_API = 'api'; + + /** + * The workspace this log belongs to. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * The namespace this log belongs to. + */ + public function namespace(): BelongsTo + { + return $this->belongsTo(Namespace_::class, 'namespace_id'); + } + + /** + * The user who triggered this action. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Scope to a specific action. + */ + public function scopeForAction($query, string $action) + { + return $query->where('action', $action); + } + + /** + * Scope to a specific entity. + */ + public function scopeForEntity($query, string $entityType, ?int $entityId = null) + { + $query->where('entity_type', $entityType); + + if ($entityId !== null) { + $query->where('entity_id', $entityId); + } + + return $query; + } + + /** + * Scope to a specific source. + */ + public function scopeFromSource($query, string $source) + { + return $query->where('source', $source); + } + + /** + * Create a log entry for a package action. + */ + public static function logPackageAction( + Workspace $workspace, + string $action, + WorkspacePackage $workspacePackage, + ?User $user = null, + ?string $source = null, + ?array $oldValues = null, + ?array $newValues = null, + ?array $metadata = null + ): self { + return self::create([ + 'workspace_id' => $workspace->id, + 'action' => $action, + 'entity_type' => WorkspacePackage::class, + 'entity_id' => $workspacePackage->id, + 'user_id' => $user?->id, + 'source' => $source, + 'old_values' => $oldValues, + 'new_values' => $newValues, + 'metadata' => $metadata, + ]); + } + + /** + * Create a log entry for a boost action. + */ + public static function logBoostAction( + Workspace $workspace, + string $action, + Boost $boost, + ?User $user = null, + ?string $source = null, + ?array $oldValues = null, + ?array $newValues = null, + ?array $metadata = null + ): self { + return self::create([ + 'workspace_id' => $workspace->id, + 'action' => $action, + 'entity_type' => Boost::class, + 'entity_id' => $boost->id, + 'user_id' => $user?->id, + 'source' => $source, + 'old_values' => $oldValues, + 'new_values' => $newValues, + 'metadata' => $metadata, + ]); + } + + /** + * Create a log entry for a usage action. + */ + public static function logUsageAction( + Workspace $workspace, + string $action, + string $featureCode, + ?User $user = null, + ?string $source = null, + ?array $metadata = null + ): self { + return self::create([ + 'workspace_id' => $workspace->id, + 'action' => $action, + 'entity_type' => 'feature', + 'entity_id' => null, + 'user_id' => $user?->id, + 'source' => $source, + 'old_values' => null, + 'new_values' => ['feature_code' => $featureCode], + 'metadata' => $metadata, + ]); + } +} diff --git a/src/Models/EntitlementWebhook.php b/src/Models/EntitlementWebhook.php new file mode 100644 index 0000000..13b2895 --- /dev/null +++ b/src/Models/EntitlementWebhook.php @@ -0,0 +1,245 @@ + 'array', + 'is_active' => 'boolean', + 'max_attempts' => 'integer', + 'last_delivery_status' => WebhookDeliveryStatus::class, + 'last_triggered_at' => 'datetime', + 'failure_count' => 'integer', + 'secret' => 'encrypted', + 'metadata' => 'array', + ]; + + protected $hidden = [ + 'secret', + ]; + + /** + * Available webhook event types. + */ + public const EVENTS = [ + 'limit_warning', + 'limit_reached', + 'package_changed', + 'boost_activated', + 'boost_expired', + ]; + + /** + * Maximum consecutive failures before auto-disable (circuit breaker). + */ + public const MAX_FAILURES = 5; + + protected static function boot(): void + { + parent::boot(); + + static::creating(function (self $webhook) { + if (empty($webhook->uuid)) { + $webhook->uuid = (string) Str::uuid(); + } + }); + } + + // ------------------------------------------------------------------------- + // Relationships + // ------------------------------------------------------------------------- + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function deliveries(): HasMany + { + return $this->hasMany(EntitlementWebhookDelivery::class, 'webhook_id'); + } + + // ------------------------------------------------------------------------- + // Scopes + // ------------------------------------------------------------------------- + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + public function scopeForEvent(Builder $query, string $event): Builder + { + return $query->whereJsonContains('events', $event); + } + + public function scopeForWorkspace(Builder $query, Workspace|int $workspace): Builder + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return $query->where('workspace_id', $workspaceId); + } + + // ------------------------------------------------------------------------- + // State checks + // ------------------------------------------------------------------------- + + public function isActive(): bool + { + return $this->is_active === true; + } + + public function hasEvent(string $event): bool + { + return in_array($event, $this->events ?? []); + } + + public function isCircuitBroken(): bool + { + return $this->failure_count >= self::MAX_FAILURES; + } + + // ------------------------------------------------------------------------- + // Status management + // ------------------------------------------------------------------------- + + public function incrementFailureCount(): void + { + $this->increment('failure_count'); + + // Auto-disable after too many failures (circuit breaker) + if ($this->failure_count >= self::MAX_FAILURES) { + $this->update(['is_active' => false]); + } + } + + public function resetFailureCount(): void + { + $this->update([ + 'failure_count' => 0, + 'last_triggered_at' => now(), + ]); + } + + public function updateLastDeliveryStatus(WebhookDeliveryStatus $status): void + { + $this->update(['last_delivery_status' => $status]); + } + + /** + * Trigger webhook and create delivery record. + */ + public function trigger(EntitlementWebhookEvent $event): EntitlementWebhookDelivery + { + $data = [ + 'event' => $event::name(), + 'data' => $event->payload(), + 'timestamp' => now()->toIso8601String(), + ]; + + try { + $headers = [ + 'Content-Type' => 'application/json', + 'X-Request-Source' => config('app.name'), + 'User-Agent' => config('app.name').' Entitlement Webhook', + ]; + + if ($this->secret) { + $headers['X-Signature'] = hash_hmac('sha256', json_encode($data), $this->secret); + } + + $response = Http::withHeaders($headers) + ->timeout(10) + ->post($this->url, $data); + + $status = match ($response->status()) { + 200, 201, 202, 204 => WebhookDeliveryStatus::SUCCESS, + default => WebhookDeliveryStatus::FAILED, + }; + + if ($status === WebhookDeliveryStatus::SUCCESS) { + $this->resetFailureCount(); + } else { + $this->incrementFailureCount(); + } + + $this->updateLastDeliveryStatus($status); + + return $this->deliveries()->create([ + 'uuid' => Str::uuid(), + 'event' => $event::name(), + 'status' => $status, + 'http_status' => $response->status(), + 'payload' => $data, + 'response' => $response->json() ?: ['body' => $response->body()], + 'created_at' => now(), + ]); + } catch (\Exception $e) { + $this->incrementFailureCount(); + $this->updateLastDeliveryStatus(WebhookDeliveryStatus::FAILED); + + return $this->deliveries()->create([ + 'uuid' => Str::uuid(), + 'event' => $event::name(), + 'status' => WebhookDeliveryStatus::FAILED, + 'payload' => $data, + 'response' => ['error' => $e->getMessage()], + 'created_at' => now(), + ]); + } + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + /** + * Generate a new secret for this webhook. + */ + public function regenerateSecret(): string + { + $secret = bin2hex(random_bytes(32)); + $this->update(['secret' => $secret]); + + return $secret; + } +} diff --git a/src/Models/EntitlementWebhookDelivery.php b/src/Models/EntitlementWebhookDelivery.php new file mode 100644 index 0000000..7d80e6b --- /dev/null +++ b/src/Models/EntitlementWebhookDelivery.php @@ -0,0 +1,139 @@ + 'integer', + 'status' => WebhookDeliveryStatus::class, + 'http_status' => 'integer', + 'resend_at' => 'datetime', + 'resent_manually' => 'boolean', + 'payload' => 'array', + 'response' => 'array', + 'created_at' => 'datetime', + ]; + + /** + * Prune deliveries older than 30 days. + */ + public function prunable(): Builder + { + return static::where('created_at', '<=', Carbon::now()->subMonth()); + } + + public function webhook(): BelongsTo + { + return $this->belongsTo(EntitlementWebhook::class, 'webhook_id'); + } + + public function isSucceeded(): bool + { + return $this->status === WebhookDeliveryStatus::SUCCESS; + } + + public function isFailed(): bool + { + return $this->status === WebhookDeliveryStatus::FAILED; + } + + public function isPending(): bool + { + return $this->status === WebhookDeliveryStatus::PENDING; + } + + public function isAttemptLimitReached(): bool + { + return $this->attempts >= $this->webhook->max_attempts; + } + + public function attempt(): void + { + $this->increment('attempts'); + } + + public function setAsResentManually(): void + { + $this->resent_manually = true; + $this->save(); + } + + public function updateResendAt(Carbon|DateTimeInterface|null $datetime = null): void + { + $this->resend_at = $datetime; + $this->save(); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + /** + * Get the event name in a human-readable format. + */ + public function getEventDisplayName(): string + { + return match ($this->event) { + 'limit_warning' => 'Limit Warning', + 'limit_reached' => 'Limit Reached', + 'package_changed' => 'Package Changed', + 'boost_activated' => 'Boost Activated', + 'boost_expired' => 'Boost Expired', + 'test' => 'Test', + default => ucwords(str_replace('_', ' ', $this->event)), + }; + } + + /** + * Get status badge colour for display. + */ + public function getStatusColour(): string + { + return match ($this->status) { + WebhookDeliveryStatus::SUCCESS => 'green', + WebhookDeliveryStatus::FAILED => 'red', + WebhookDeliveryStatus::PENDING => 'amber', + default => 'gray', + }; + } +} diff --git a/src/Models/Feature.php b/src/Models/Feature.php new file mode 100644 index 0000000..6bbf8ab --- /dev/null +++ b/src/Models/Feature.php @@ -0,0 +1,159 @@ + 'integer', + 'sort_order' => 'integer', + 'is_active' => 'boolean', + ]; + + /** + * Feature types. + */ + public const TYPE_BOOLEAN = 'boolean'; + + public const TYPE_LIMIT = 'limit'; + + public const TYPE_UNLIMITED = 'unlimited'; + + /** + * Reset types. + */ + public const RESET_NONE = 'none'; + + public const RESET_MONTHLY = 'monthly'; + + public const RESET_ROLLING = 'rolling'; + + /** + * Packages that include this feature. + */ + public function packages(): BelongsToMany + { + return $this->belongsToMany(Package::class, 'entitlement_package_features', 'feature_id', 'package_id') + ->withPivot('limit_value') + ->withTimestamps(); + } + + /** + * Parent feature (for hierarchical limits / global pools). + */ + public function parent(): BelongsTo + { + return $this->belongsTo(Feature::class, 'parent_feature_id'); + } + + /** + * Child features (allowances within a global pool). + */ + public function children(): HasMany + { + return $this->hasMany(Feature::class, 'parent_feature_id'); + } + + /** + * Scope to active features. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope to features in a category. + */ + public function scopeInCategory($query, string $category) + { + return $query->where('category', $category); + } + + /** + * Scope to root features (no parent). + */ + public function scopeRoot($query) + { + return $query->whereNull('parent_feature_id'); + } + + /** + * Check if this feature is a boolean toggle. + */ + public function isBoolean(): bool + { + return $this->type === self::TYPE_BOOLEAN; + } + + /** + * Check if this feature has a usage limit. + */ + public function hasLimit(): bool + { + return $this->type === self::TYPE_LIMIT; + } + + /** + * Check if this feature is unlimited. + */ + public function isUnlimited(): bool + { + return $this->type === self::TYPE_UNLIMITED; + } + + /** + * Check if this feature resets monthly. + */ + public function resetsMonthly(): bool + { + return $this->reset_type === self::RESET_MONTHLY; + } + + /** + * Check if this feature uses rolling window reset. + */ + public function resetsRolling(): bool + { + return $this->reset_type === self::RESET_ROLLING; + } + + /** + * Check if this is a child feature (part of a global pool). + */ + public function isChildFeature(): bool + { + return $this->parent_feature_id !== null; + } + + /** + * Get the global pool feature code (parent or self). + */ + public function getPoolFeatureCode(): string + { + return $this->parent?->code ?? $this->code; + } +} diff --git a/src/Models/NamespacePackage.php b/src/Models/NamespacePackage.php new file mode 100644 index 0000000..3f94bf7 --- /dev/null +++ b/src/Models/NamespacePackage.php @@ -0,0 +1,176 @@ + 'datetime', + 'expires_at' => 'datetime', + 'billing_cycle_anchor' => 'datetime', + 'metadata' => 'array', + ]; + + /** + * Status constants. + */ + public const STATUS_ACTIVE = 'active'; + + public const STATUS_SUSPENDED = 'suspended'; + + public const STATUS_CANCELLED = 'cancelled'; + + public const STATUS_EXPIRED = 'expired'; + + /** + * The namespace this package belongs to. + */ + public function namespace(): BelongsTo + { + return $this->belongsTo(Namespace_::class, 'namespace_id'); + } + + /** + * The package definition. + */ + public function package(): BelongsTo + { + return $this->belongsTo(Package::class, 'package_id'); + } + + /** + * Scope to active assignments. + */ + public function scopeActive($query) + { + return $query->where('status', self::STATUS_ACTIVE); + } + + /** + * Scope to non-expired assignments. + */ + public function scopeNotExpired($query) + { + return $query->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } + + /** + * Check if this assignment is currently active. + */ + public function isActive(): bool + { + if ($this->status !== self::STATUS_ACTIVE) { + return false; + } + + if ($this->starts_at && $this->starts_at->isFuture()) { + return false; + } + + if ($this->expires_at && $this->expires_at->isPast()) { + return false; + } + + return true; + } + + /** + * Check if this assignment is on grace period. + */ + public function onGracePeriod(): bool + { + return $this->status === self::STATUS_CANCELLED + && $this->expires_at + && $this->expires_at->isFuture(); + } + + /** + * Get the current billing cycle start date. + */ + public function getCurrentCycleStart(): Carbon + { + if (! $this->billing_cycle_anchor) { + return $this->starts_at ?? $this->created_at; + } + + $anchor = $this->billing_cycle_anchor->copy(); + $now = now(); + + // Find the most recent cycle start + while ($anchor->addMonth()->lte($now)) { + // Keep advancing until we pass now + } + + return $anchor->subMonth(); + } + + /** + * Get the current billing cycle end date. + */ + public function getCurrentCycleEnd(): Carbon + { + return $this->getCurrentCycleStart()->copy()->addMonth(); + } + + /** + * Suspend this assignment. + */ + public function suspend(): void + { + $this->update(['status' => self::STATUS_SUSPENDED]); + } + + /** + * Reactivate this assignment. + */ + public function reactivate(): void + { + $this->update(['status' => self::STATUS_ACTIVE]); + } + + /** + * Cancel this assignment. + */ + public function cancel(?Carbon $endsAt = null): void + { + $this->update([ + 'status' => self::STATUS_CANCELLED, + 'expires_at' => $endsAt ?? $this->getCurrentCycleEnd(), + ]); + } +} diff --git a/src/Models/Namespace_.php b/src/Models/Namespace_.php new file mode 100644 index 0000000..6b67c09 --- /dev/null +++ b/src/Models/Namespace_.php @@ -0,0 +1,321 @@ + 'array', + 'is_default' => 'boolean', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + + /** + * Boot the model. + */ + protected static function booted(): void + { + static::creating(function (self $namespace) { + if (empty($namespace->uuid)) { + $namespace->uuid = (string) Str::uuid(); + } + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Ownership Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get the owner of the namespace (User or Workspace). + */ + public function owner(): MorphTo + { + return $this->morphTo(); + } + + /** + * Get the workspace for billing aggregation (if set). + * + * This is separate from owner - a user-owned namespace can still + * have a workspace context for billing purposes. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * Check if this namespace is owned by a user. + */ + public function isOwnedByUser(): bool + { + return $this->owner_type === User::class; + } + + /** + * Check if this namespace is owned by a workspace. + */ + public function isOwnedByWorkspace(): bool + { + return $this->owner_type === Workspace::class; + } + + /** + * Get the owner as User (or null if workspace-owned). + */ + public function getOwnerUser(): ?User + { + if ($this->isOwnedByUser()) { + return $this->owner; + } + + return null; + } + + /** + * Get the owner as Workspace (or null if user-owned). + */ + public function getOwnerWorkspace(): ?Workspace + { + if ($this->isOwnedByWorkspace()) { + return $this->owner; + } + + return null; + } + + // ───────────────────────────────────────────────────────────────────────── + // Entitlement Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Active package assignments for this namespace. + */ + public function namespacePackages(): HasMany + { + return $this->hasMany(NamespacePackage::class); + } + + /** + * Active boosts for this namespace. + */ + public function boosts(): HasMany + { + return $this->hasMany(Boost::class); + } + + /** + * Usage records for this namespace. + */ + public function usageRecords(): HasMany + { + return $this->hasMany(UsageRecord::class); + } + + /** + * Entitlement logs for this namespace. + */ + public function entitlementLogs(): HasMany + { + return $this->hasMany(EntitlementLog::class); + } + + // ───────────────────────────────────────────────────────────────────────── + // Settings & Configuration + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get a setting value from the settings JSON column. + */ + public function getSetting(string $key, mixed $default = null): mixed + { + return data_get($this->settings, $key, $default); + } + + /** + * Set a setting value in the settings JSON column. + */ + public function setSetting(string $key, mixed $value): self + { + $settings = $this->settings ?? []; + data_set($settings, $key, $value); + $this->settings = $settings; + + return $this; + } + + // ───────────────────────────────────────────────────────────────────────── + // Scopes + // ───────────────────────────────────────────────────────────────────────── + + /** + * Scope to only active namespaces. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope to order by sort order. + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort_order'); + } + + /** + * Scope to namespaces owned by a specific user. + */ + public function scopeOwnedByUser($query, User|int $user) + { + $userId = $user instanceof User ? $user->id : $user; + + return $query->where('owner_type', User::class) + ->where('owner_id', $userId); + } + + /** + * Scope to namespaces owned by a specific workspace. + */ + public function scopeOwnedByWorkspace($query, Workspace|int $workspace) + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return $query->where('owner_type', Workspace::class) + ->where('owner_id', $workspaceId); + } + + /** + * Scope to namespaces accessible by a user (owned by user OR owned by user's workspaces). + */ + public function scopeAccessibleBy($query, User $user) + { + $workspaceIds = $user->workspaces()->pluck('workspaces.id'); + + return $query->where(function ($q) use ($user, $workspaceIds) { + // User-owned namespaces + $q->where(function ($q2) use ($user) { + $q2->where('owner_type', User::class) + ->where('owner_id', $user->id); + }); + + // Workspace-owned namespaces (where user is a member) + if ($workspaceIds->isNotEmpty()) { + $q->orWhere(function ($q2) use ($workspaceIds) { + $q2->where('owner_type', Workspace::class) + ->whereIn('owner_id', $workspaceIds); + }); + } + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helper Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Check if a user has access to this namespace. + */ + public function isAccessibleBy(User $user): bool + { + // User owns the namespace directly + if ($this->isOwnedByUser() && $this->owner_id === $user->id) { + return true; + } + + // Workspace owns the namespace and user is a member + if ($this->isOwnedByWorkspace()) { + return $user->workspaces()->where('workspaces.id', $this->owner_id)->exists(); + } + + return false; + } + + /** + * Get the billing context for this namespace. + * + * Returns workspace if set, otherwise falls back to owner's default workspace. + */ + public function getBillingContext(): ?Workspace + { + // Explicit workspace set for billing + if ($this->workspace_id) { + return $this->workspace; + } + + // Workspace-owned: use the owner workspace + if ($this->isOwnedByWorkspace()) { + return $this->owner; + } + + // User-owned: fall back to user's default workspace + if ($this->isOwnedByUser() && $this->owner) { + return $this->owner->defaultHostWorkspace(); + } + + return null; + } + + /** + * Get the route key name for route model binding. + */ + public function getRouteKeyName(): string + { + return 'uuid'; + } +} diff --git a/src/Models/Package.php b/src/Models/Package.php new file mode 100644 index 0000000..e8ef1b0 --- /dev/null +++ b/src/Models/Package.php @@ -0,0 +1,244 @@ + 'boolean', + 'is_base_package' => 'boolean', + 'is_active' => 'boolean', + 'is_public' => 'boolean', + 'sort_order' => 'integer', + 'monthly_price' => 'decimal:2', + 'yearly_price' => 'decimal:2', + 'setup_fee' => 'decimal:2', + 'trial_days' => 'integer', + ]; + + /** + * Features included in this package. + */ + public function features(): BelongsToMany + { + return $this->belongsToMany(Feature::class, 'entitlement_package_features', 'package_id', 'feature_id') + ->withPivot('limit_value') + ->withTimestamps(); + } + + /** + * Workspaces that have this package assigned. + */ + public function workspacePackages(): HasMany + { + return $this->hasMany(WorkspacePackage::class, 'package_id'); + } + + /** + * Scope to active packages. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope to public packages (shown on pricing page). + */ + public function scopePublic($query) + { + return $query->where('is_public', true); + } + + /** + * Scope to base packages (only one per workspace). + */ + public function scopeBase($query) + { + return $query->where('is_base_package', true); + } + + /** + * Scope to addon packages (stackable). + */ + public function scopeAddons($query) + { + return $query->where('is_base_package', false); + } + + /** + * Get the limit for a specific feature in this package. + */ + public function getFeatureLimit(string $featureCode): ?int + { + $feature = $this->features()->where('code', $featureCode)->first(); + + if (! $feature) { + return null; + } + + return $feature->pivot->limit_value; + } + + /** + * Check if package includes a feature (regardless of limit). + */ + public function hasFeature(string $featureCode): bool + { + return $this->features()->where('code', $featureCode)->exists(); + } + + // Pricing Helpers + + /** + * Check if package is free. + */ + public function isFree(): bool + { + return ($this->monthly_price ?? 0) == 0 && ($this->yearly_price ?? 0) == 0; + } + + /** + * Check if package has pricing set. + */ + public function hasPricing(): bool + { + return $this->monthly_price !== null || $this->yearly_price !== null; + } + + /** + * Get price for a billing cycle. + */ + public function getPrice(string $cycle = 'monthly'): float + { + return match ($cycle) { + 'yearly', 'annual' => (float) ($this->yearly_price ?? 0), + default => (float) ($this->monthly_price ?? 0), + }; + } + + /** + * Get yearly savings compared to monthly. + */ + public function getYearlySavings(): float + { + if (! $this->monthly_price || ! $this->yearly_price) { + return 0; + } + + $monthlyTotal = $this->monthly_price * 12; + + return max(0, $monthlyTotal - $this->yearly_price); + } + + /** + * Get yearly savings as percentage. + */ + public function getYearlySavingsPercent(): int + { + if (! $this->monthly_price || ! $this->yearly_price) { + return 0; + } + + $monthlyTotal = $this->monthly_price * 12; + if ($monthlyTotal == 0) { + return 0; + } + + return (int) round(($this->getYearlySavings() / $monthlyTotal) * 100); + } + + /** + * Get gateway price ID for a cycle. + */ + public function getGatewayPriceId(string $gateway, string $cycle = 'monthly'): ?string + { + $field = match ($cycle) { + 'yearly', 'annual' => "{$gateway}_price_id_yearly", + default => "{$gateway}_price_id_monthly", + }; + + return $this->{$field}; + } + + /** + * Check if package has trial period. + */ + public function hasTrial(): bool + { + return ($this->trial_days ?? 0) > 0; + } + + /** + * Check if package has setup fee. + */ + public function hasSetupFee(): bool + { + return ($this->setup_fee ?? 0) > 0; + } + + /** + * Scope to packages with pricing (purchasable). + */ + public function scopePurchasable($query) + { + return $query->where(function ($q) { + $q->whereNotNull('monthly_price') + ->orWhereNotNull('yearly_price'); + }); + } + + /** + * Scope to free packages. + */ + public function scopeFree($query) + { + return $query->where(function ($q) { + $q->whereNull('monthly_price') + ->orWhere('monthly_price', 0); + })->where(function ($q) { + $q->whereNull('yearly_price') + ->orWhere('yearly_price', 0); + }); + } + + /** + * Scope to order by sort_order. + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort_order'); + } +} diff --git a/src/Models/UsageAlertHistory.php b/src/Models/UsageAlertHistory.php new file mode 100644 index 0000000..857fa22 --- /dev/null +++ b/src/Models/UsageAlertHistory.php @@ -0,0 +1,198 @@ + 'integer', + 'notified_at' => 'datetime', + 'resolved_at' => 'datetime', + 'metadata' => 'array', + ]; + + /** + * Alert threshold levels. + */ + public const THRESHOLD_WARNING = 80; + + public const THRESHOLD_CRITICAL = 90; + + public const THRESHOLD_LIMIT = 100; + + /** + * All threshold levels in order. + */ + public const THRESHOLDS = [ + self::THRESHOLD_WARNING, + self::THRESHOLD_CRITICAL, + self::THRESHOLD_LIMIT, + ]; + + /** + * The workspace this alert belongs to. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * Scope to alerts for a specific workspace. + */ + public function scopeForWorkspace($query, int $workspaceId) + { + return $query->where('workspace_id', $workspaceId); + } + + /** + * Scope to alerts for a specific feature. + */ + public function scopeForFeature($query, string $featureCode) + { + return $query->where('feature_code', $featureCode); + } + + /** + * Scope to alerts for a specific threshold. + */ + public function scopeForThreshold($query, int $threshold) + { + return $query->where('threshold', $threshold); + } + + /** + * Scope to unresolved alerts (still active). + */ + public function scopeUnresolved($query) + { + return $query->whereNull('resolved_at'); + } + + /** + * Scope to resolved alerts. + */ + public function scopeResolved($query) + { + return $query->whereNotNull('resolved_at'); + } + + /** + * Scope to recent alerts (within given days). + */ + public function scopeRecent($query, int $days = 7) + { + return $query->where('notified_at', '>=', now()->subDays($days)); + } + + /** + * Check if an alert has been sent for this workspace/feature/threshold combo. + * Only considers unresolved alerts. + */ + public static function hasActiveAlert(int $workspaceId, string $featureCode, int $threshold): bool + { + return static::query() + ->forWorkspace($workspaceId) + ->forFeature($featureCode) + ->forThreshold($threshold) + ->unresolved() + ->exists(); + } + + /** + * Get the most recent unresolved alert for a workspace/feature. + */ + public static function getActiveAlert(int $workspaceId, string $featureCode): ?self + { + return static::query() + ->forWorkspace($workspaceId) + ->forFeature($featureCode) + ->unresolved() + ->latest('notified_at') + ->first(); + } + + /** + * Record a new alert being sent. + */ + public static function record( + int $workspaceId, + string $featureCode, + int $threshold, + array $metadata = [] + ): self { + return static::create([ + 'workspace_id' => $workspaceId, + 'feature_code' => $featureCode, + 'threshold' => $threshold, + 'notified_at' => now(), + 'metadata' => $metadata, + ]); + } + + /** + * Mark this alert as resolved (usage dropped below threshold). + */ + public function resolve(): self + { + $this->update(['resolved_at' => now()]); + + return $this; + } + + /** + * Resolve all unresolved alerts for a workspace/feature. + */ + public static function resolveAllForFeature(int $workspaceId, string $featureCode): int + { + return static::query() + ->forWorkspace($workspaceId) + ->forFeature($featureCode) + ->unresolved() + ->update(['resolved_at' => now()]); + } + + /** + * Check if this alert is resolved. + */ + public function isResolved(): bool + { + return $this->resolved_at !== null; + } + + /** + * Get the threshold level name. + */ + public function getThresholdName(): string + { + return match ($this->threshold) { + self::THRESHOLD_WARNING => 'warning', + self::THRESHOLD_CRITICAL => 'critical', + self::THRESHOLD_LIMIT => 'limit_reached', + default => 'unknown', + }; + } +} diff --git a/src/Models/UsageRecord.php b/src/Models/UsageRecord.php new file mode 100644 index 0000000..ec44d7f --- /dev/null +++ b/src/Models/UsageRecord.php @@ -0,0 +1,121 @@ + 'integer', + 'metadata' => 'array', + 'recorded_at' => 'datetime', + ]; + + /** + * The workspace this usage belongs to. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * The namespace this usage belongs to. + */ + public function namespace(): BelongsTo + { + return $this->belongsTo(Namespace_::class, 'namespace_id'); + } + + /** + * The user who incurred this usage. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Scope to a specific feature. + */ + public function scopeForFeature($query, string $featureCode) + { + return $query->where('feature_code', $featureCode); + } + + /** + * Scope to records since a date. + */ + public function scopeSince($query, Carbon $date) + { + return $query->where('recorded_at', '>=', $date); + } + + /** + * Scope to records in a date range. + */ + public function scopeBetween($query, Carbon $start, Carbon $end) + { + return $query->whereBetween('recorded_at', [$start, $end]); + } + + /** + * Scope to records in the current billing cycle. + */ + public function scopeInCurrentCycle($query, Carbon $cycleStart) + { + return $query->where('recorded_at', '>=', $cycleStart); + } + + /** + * Scope to records in a rolling window. + */ + public function scopeInRollingWindow($query, int $days) + { + return $query->where('recorded_at', '>=', now()->subDays($days)); + } + + /** + * Get total usage for a workspace + feature since a date. + */ + public static function getTotalUsage(int $workspaceId, string $featureCode, ?Carbon $since = null): int + { + $query = static::where('workspace_id', $workspaceId) + ->where('feature_code', $featureCode); + + if ($since) { + $query->where('recorded_at', '>=', $since); + } + + return (int) $query->sum('quantity'); + } + + /** + * Get total usage in a rolling window. + */ + public static function getRollingUsage(int $workspaceId, string $featureCode, int $days): int + { + return static::where('workspace_id', $workspaceId) + ->where('feature_code', $featureCode) + ->where('recorded_at', '>=', now()->subDays($days)) + ->sum('quantity'); + } +} diff --git a/src/Models/User.php b/src/Models/User.php new file mode 100644 index 0000000..d4e4b45 --- /dev/null +++ b/src/Models/User.php @@ -0,0 +1,596 @@ + + */ + protected $fillable = [ + 'name', + 'email', + 'password', + 'tier', + 'tier_expires_at', + 'referred_by', + 'referral_count', + 'referral_activated_at', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'tier' => UserTier::class, + 'tier_expires_at' => 'datetime', + 'cached_stats' => 'array', + 'stats_computed_at' => 'datetime', + 'referral_activated_at' => 'datetime', + ]; + } + + /** + * Get all workspaces this user has access to. + */ + public function workspaces(): BelongsToMany + { + return $this->belongsToMany(Workspace::class, 'user_workspace') + ->withPivot(['role', 'is_default']) + ->withTimestamps(); + } + + /** + * Alias for workspaces() - kept for backward compatibility. + */ + public function hostWorkspaces(): BelongsToMany + { + return $this->workspaces(); + } + + /** + * Get the workspaces owned by this user. + */ + public function ownedWorkspaces(): BelongsToMany + { + return $this->belongsToMany(Workspace::class, 'user_workspace') + ->wherePivot('role', 'owner') + ->withPivot(['role', 'is_default']) + ->withTimestamps(); + } + + /** + * Get the user's tier. + */ + public function getTier(): UserTier + { + // Check if tier has expired + if ($this->tier_expires_at && $this->tier_expires_at->isPast()) { + return UserTier::FREE; + } + + return $this->tier ?? UserTier::FREE; + } + + /** + * Check if user is on a paid tier. + */ + public function isPaid(): bool + { + $tier = $this->getTier(); + + return $tier === UserTier::APOLLO || $tier === UserTier::HADES; + } + + /** + * Check if user is on Hades tier. + */ + public function isHades(): bool + { + return $this->getTier() === UserTier::HADES; + } + + /** + * Check if user is on Apollo tier. + */ + public function isApollo(): bool + { + return $this->getTier() === UserTier::APOLLO; + } + + /** + * Check if user has a specific feature. + */ + public function hasFeature(string $feature): bool + { + return $this->getTier()->hasFeature($feature); + } + + /** + * Get the maximum number of workspaces for this user. + */ + public function maxWorkspaces(): int + { + return $this->getTier()->maxWorkspaces(); + } + + /** + * Check if user can add more Host Hub workspaces. + */ + public function canAddHostWorkspace(): bool + { + $max = $this->maxWorkspaces(); + if ($max === -1) { + return true; // Unlimited + } + + return $this->hostWorkspaces()->count() < $max; + } + + /** + * Get the user's default Host Hub workspace. + */ + public function defaultHostWorkspace(): ?Workspace + { + return $this->hostWorkspaces() + ->wherePivot('is_default', true) + ->first() ?? $this->hostWorkspaces()->first(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Namespace Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all namespaces owned directly by this user. + */ + public function namespaces(): MorphMany + { + return $this->morphMany(Namespace_::class, 'owner'); + } + + /** + * Get the user's default namespace. + * + * Priority: + * 1. User's default namespace (is_default = true) + * 2. First active user-owned namespace + * 3. First namespace from user's default workspace + */ + public function defaultNamespace(): ?Namespace_ + { + // Try user's explicit default + $default = $this->namespaces() + ->where('is_default', true) + ->active() + ->first(); + + if ($default) { + return $default; + } + + // Try first user-owned namespace + $userOwned = $this->namespaces() + ->active() + ->ordered() + ->first(); + + if ($userOwned) { + return $userOwned; + } + + // Try namespace from user's default workspace + $workspace = $this->defaultHostWorkspace(); + if ($workspace) { + return $workspace->namespaces() + ->active() + ->ordered() + ->first(); + } + + return null; + } + + /** + * Get all namespaces accessible by this user (owned + via workspaces). + */ + public function accessibleNamespaces(): \Illuminate\Database\Eloquent\Builder + { + return Namespace_::accessibleBy($this); + } + + /** + * Check if user's email has been verified. + * Hades accounts are always considered verified. + */ + public function hasVerifiedEmail(): bool + { + // Hades accounts bypass email verification + if ($this->isHades()) { + return true; + } + + return $this->email_verified_at !== null; + } + + /** + * Mark the user's email as verified. + */ + public function markEmailAsVerified(): bool + { + return $this->forceFill([ + 'email_verified_at' => $this->freshTimestamp(), + ])->save(); + } + + /** + * Send the email verification notification. + */ + public function sendEmailVerificationNotification(): void + { + $this->notify(new \Illuminate\Auth\Notifications\VerifyEmail); + } + + /** + * Get the email address that should be used for verification. + */ + public function getEmailForVerification(): string + { + return $this->email; + } + + // ───────────────────────────────────────────────────────────────────────── + // Page Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all pages owned by this user. + */ + public function pages(): HasMany + { + return $this->hasMany(Page::class); + } + + /** + * Get all page projects (folders) owned by this user. + */ + public function pageProjects(): HasMany + { + return $this->hasMany(Project::class); + } + + /** + * Get all custom domains owned by this user. + */ + public function pageDomains(): HasMany + { + return $this->hasMany(Domain::class); + } + + /** + * Get all tracking pixels owned by this user. + */ + public function pagePixels(): HasMany + { + return $this->hasMany(Pixel::class); + } + + // ───────────────────────────────────────────────────────────────────────── + // Analytics Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all analytics websites owned by this user. + */ + public function analyticsWebsites(): HasMany + { + return $this->hasMany(AnalyticsWebsite::class); + } + + /** + * Get all analytics goals owned by this user. + */ + public function analyticsGoals(): HasMany + { + return $this->hasMany(AnalyticsGoal::class); + } + + // ───────────────────────────────────────────────────────────────────────── + // Push Notification Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all push websites owned by this user. + */ + public function pushWebsites(): HasMany + { + return $this->hasMany(PushWebsite::class); + } + + /** + * Get all push campaigns owned by this user. + */ + public function pushCampaigns(): HasMany + { + return $this->hasMany(PushCampaign::class); + } + + /** + * Get all push segments owned by this user. + */ + public function pushSegments(): HasMany + { + return $this->hasMany(PushSegment::class); + } + + /** + * Get all push flows owned by this user. + */ + public function pushFlows(): HasMany + { + return $this->hasMany(PushFlow::class); + } + + // ───────────────────────────────────────────────────────────────────────── + // Trust Widget Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all trust campaigns owned by this user. + */ + public function trustCampaigns(): HasMany + { + return $this->hasMany(TrustCampaign::class); + } + + /** + * Get all trust notifications owned by this user. + */ + public function trustNotifications(): HasMany + { + return $this->hasMany(TrustNotification::class); + } + + // ───────────────────────────────────────────────────────────────────────── + // Entitlement Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all boosts owned by this user. + */ + public function boosts(): HasMany + { + return $this->hasMany(Boost::class); + } + + /** + * Get all orders placed by this user. + */ + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + /** + * Check if user can claim a vanity URL. + * + * Requires either: + * - A paid subscription (Creator/Agency package) + * - A one-time vanity URL boost purchase + */ + public function canClaimVanityUrl(): bool + { + // Check for vanity URL boost + $hasBoost = $this->boosts() + ->where('feature_code', 'bio.vanity_url') + ->where('status', Boost::STATUS_ACTIVE) + ->exists(); + + if ($hasBoost) { + return true; + } + + // Check for paid subscription (Creator or Agency package) + // An order with total > 0 and status = 'paid' indicates a paid subscription + $hasPaidSubscription = $this->orders() + ->where('status', 'paid') + ->where('total', '>', 0) + ->whereHas('items', function ($query) { + $query->whereIn('item_code', ['creator', 'agency']); + }) + ->exists(); + + return $hasPaidSubscription; + } + + /** + * Get the user's bio.pages entitlement (base + boosts). + */ + public function getBioPagesLimit(): int + { + // Base: 1 page for all tiers + $base = 1; + + // Add from boosts + $boostPages = $this->boosts() + ->where('feature_code', 'bio.pages') + ->where('status', Boost::STATUS_ACTIVE) + ->sum('limit_value'); + + return $base + (int) $boostPages; + } + + /** + * Check if user can create more bio pages. + */ + public function canCreateBioPage(): bool + { + return $this->pages()->rootPages()->count() < $this->getBioPagesLimit(); + } + + /** + * Get remaining bio page slots. + */ + public function remainingBioPageSlots(): int + { + return max(0, $this->getBioPagesLimit() - $this->pages()->rootPages()->count()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Sub-Page Entitlements + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get the user's sub-page limit (0 base + boosts). + */ + public function getSubPagesLimit(): int + { + // Base: 0 sub-pages (free tier) + $base = 0; + + // Add from boosts + $boostPages = $this->boosts() + ->where('feature_code', 'webpage.sub_pages') + ->where('status', Boost::STATUS_ACTIVE) + ->sum('limit_value'); + + return $base + (int) $boostPages; + } + + /** + * Get the total sub-pages count across all root pages. + */ + public function getSubPagesCount(): int + { + return $this->pages()->subPages()->count(); + } + + /** + * Check if user can create more sub-pages. + */ + public function canCreateSubPage(): bool + { + return $this->getSubPagesCount() < $this->getSubPagesLimit(); + } + + /** + * Get remaining sub-page slots. + */ + public function remainingSubPageSlots(): int + { + return max(0, $this->getSubPagesLimit() - $this->getSubPagesCount()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Referral Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get the user who referred this user. + */ + public function referrer(): BelongsTo + { + return $this->belongsTo(self::class, 'referred_by'); + } + + /** + * Get all users referred by this user. + */ + public function referrals(): HasMany + { + return $this->hasMany(self::class, 'referred_by'); + } + + /** + * Check if user has activated referrals. + */ + public function hasActivatedReferrals(): bool + { + return $this->referral_activated_at !== null; + } + + /** + * Activate referrals for this user. + */ + public function activateReferrals(): void + { + if (! $this->hasActivatedReferrals()) { + $this->update(['referral_activated_at' => now()]); + } + } + + /** + * Get referral ranking (1-based position among all users by referral count). + */ + public function getReferralRank(): int + { + if ($this->referral_count === 0) { + return 0; // Not ranked if no referrals + } + + return self::where('referral_count', '>', $this->referral_count)->count() + 1; + } + + // ───────────────────────────────────────────────────────────────────────── + // Orderable Interface + // ───────────────────────────────────────────────────────────────────────── + + public function getBillingName(): ?string + { + return $this->name; + } + + public function getBillingEmail(): string + { + return $this->email; + } + + public function getBillingAddress(): ?array + { + return null; + } + + public function getTaxCountry(): ?string + { + return null; + } +} diff --git a/src/Models/UserToken.php b/src/Models/UserToken.php new file mode 100644 index 0000000..8f986e8 --- /dev/null +++ b/src/Models/UserToken.php @@ -0,0 +1,126 @@ + + */ + protected $fillable = [ + 'name', + 'token', + 'expires_at', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'last_used_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var array + */ + protected $hidden = [ + 'token', + ]; + + /** + * Find a token by its plain-text value. + * + * Tokens are stored as SHA-256 hashes, so we hash the input + * before querying the database. + * + * @param string $token Plain-text token value + */ + public static function findToken(string $token): ?UserToken + { + return static::where('token', hash('sha256', $token))->first(); + } + + /** + * Get the user that owns the token. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Determine if the token has expired. + */ + public function isExpired(): bool + { + return $this->expires_at && $this->expires_at->isPast(); + } + + /** + * Determine if the token is valid (not expired). + */ + public function isValid(): bool + { + return ! $this->isExpired(); + } + + /** + * Update the last used timestamp. + * + * Preserves the hasModifiedRecords state to avoid triggering + * model events when only updating usage tracking. + */ + public function recordUsage(): void + { + $connection = $this->getConnection(); + + // Preserve modification state if the connection supports it + if (method_exists($connection, 'hasModifiedRecords') && + method_exists($connection, 'setRecordModificationState')) { + + $hasModifiedRecords = $connection->hasModifiedRecords(); + + $this->forceFill(['last_used_at' => now()])->save(); + + $connection->setRecordModificationState($hasModifiedRecords); + } else { + // Fallback for connections that don't support modification state + $this->forceFill(['last_used_at' => now()])->save(); + } + } +} diff --git a/src/Models/UserTwoFactorAuth.php b/src/Models/UserTwoFactorAuth.php new file mode 100644 index 0000000..f969303 --- /dev/null +++ b/src/Models/UserTwoFactorAuth.php @@ -0,0 +1,38 @@ + 'collection', + 'confirmed_at' => 'datetime', + ]; + + /** + * Get the user this 2FA belongs to. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/src/Models/WaitlistEntry.php b/src/Models/WaitlistEntry.php new file mode 100644 index 0000000..092bb96 --- /dev/null +++ b/src/Models/WaitlistEntry.php @@ -0,0 +1,126 @@ + 'datetime', + 'registered_at' => 'datetime', + ]; + + /** + * Get the user this waitlist entry converted to. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Scope to entries that haven't been invited yet. + */ + public function scopePending($query) + { + return $query->whereNull('invited_at'); + } + + /** + * Scope to entries that have been invited but not registered. + */ + public function scopeInvited($query) + { + return $query->whereNotNull('invited_at')->whereNull('registered_at'); + } + + /** + * Scope to entries that have converted to users. + */ + public function scopeConverted($query) + { + return $query->whereNotNull('registered_at'); + } + + /** + * Generate a unique invite code for this entry. + */ + public function generateInviteCode(): string + { + $code = strtoupper(Str::random(8)); + + // Ensure uniqueness + while (static::where('invite_code', $code)->exists()) { + $code = strtoupper(Str::random(8)); + } + + $this->update([ + 'invite_code' => $code, + 'invited_at' => now(), + 'bonus_code' => 'LAUNCH50', // Default launch bonus + ]); + + return $code; + } + + /** + * Mark this entry as registered. + */ + public function markAsRegistered(User $user): void + { + $this->update([ + 'registered_at' => now(), + 'user_id' => $user->id, + ]); + } + + /** + * Check if this entry has been invited. + */ + public function isInvited(): bool + { + return $this->invited_at !== null; + } + + /** + * Check if this entry has converted to a user. + */ + public function hasConverted(): bool + { + return $this->registered_at !== null; + } + + /** + * Find entry by invite code. + */ + public static function findByInviteCode(string $code): ?self + { + return static::where('invite_code', strtoupper($code))->first(); + } +} diff --git a/src/Models/Workspace.php b/src/Models/Workspace.php new file mode 100644 index 0000000..1612846 --- /dev/null +++ b/src/Models/Workspace.php @@ -0,0 +1,834 @@ + 'array', + 'is_active' => 'boolean', + 'wp_connector_enabled' => 'boolean', + 'wp_connector_verified_at' => 'datetime', + 'wp_connector_last_sync' => 'datetime', + 'wp_connector_config' => 'array', + 'tax_exempt' => 'boolean', + ]; + + /** + * Hidden attributes (sensitive data). + */ + protected $hidden = [ + 'wp_connector_secret', + ]; + + /** + * Get the users that have access to this workspace. + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'user_workspace') + ->withPivot(['role', 'is_default', 'team_id', 'custom_permissions', 'joined_at', 'invited_by']) + ->withTimestamps(); + } + + /** + * Get workspace members (via the enhanced pivot model). + */ + public function members(): HasMany + { + return $this->hasMany(WorkspaceMember::class); + } + + /** + * Get teams defined for this workspace. + */ + public function teams(): HasMany + { + return $this->hasMany(WorkspaceTeam::class); + } + + /** + * Get the workspace owner (user with 'owner' role). + */ + public function owner(): ?User + { + return $this->users() + ->wherePivot('role', 'owner') + ->first(); + } + + /** + * Get the default team for new members. + */ + public function defaultTeam(): ?WorkspaceTeam + { + return $this->teams()->where('is_default', true)->first(); + } + + /** + * Active package assignments for this workspace. + */ + public function workspacePackages(): HasMany + { + return $this->hasMany(WorkspacePackage::class); + } + + /** + * Get pending invitations for this workspace. + */ + public function invitations(): HasMany + { + return $this->hasMany(WorkspaceInvitation::class); + } + + /** + * Get pending invitations only. + */ + public function pendingInvitations(): HasMany + { + return $this->invitations()->pending(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Namespace Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all namespaces owned by this workspace. + */ + public function namespaces(): MorphMany + { + return $this->morphMany(Namespace_::class, 'owner'); + } + + /** + * Get the workspace's default namespace. + */ + public function defaultNamespace(): ?Namespace_ + { + return $this->namespaces() + ->where('is_default', true) + ->active() + ->first() + ?? $this->namespaces()->active()->ordered()->first(); + } + + /** + * The package definitions assigned to this workspace. + */ + public function packages(): BelongsToMany + { + return $this->belongsToMany(Package::class, 'entitlement_workspace_packages', 'workspace_id', 'package_id') + ->withPivot(['status', 'starts_at', 'expires_at', 'metadata']) + ->withTimestamps(); + } + + /** + * Get a setting from the settings JSON column. + */ + public function getSetting(string $key, mixed $default = null): mixed + { + return data_get($this->settings, $key, $default); + } + + /** + * Active boosts for this workspace. + */ + public function boosts(): HasMany + { + return $this->hasMany(Boost::class); + } + + /** + * Usage records for this workspace. + */ + public function usageRecords(): HasMany + { + return $this->hasMany(UsageRecord::class); + } + + /** + * Entitlement logs for this workspace. + */ + public function entitlementLogs(): HasMany + { + return $this->hasMany(EntitlementLog::class); + } + + /** + * Usage alert history for this workspace. + */ + public function usageAlerts(): HasMany + { + return $this->hasMany(UsageAlertHistory::class); + } + + /** + * Get active (unresolved) usage alerts for this workspace. + */ + public function activeUsageAlerts(): HasMany + { + return $this->usageAlerts()->whereNull('resolved_at'); + } + + // SocialHost Relationships (Native) + + /** + * Get social accounts for this workspace. + */ + public function socialAccounts(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\Account::class); + } + + /** + * Get social posts for this workspace. + */ + public function socialPosts(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\Post::class); + } + + /** + * Get social media templates for this workspace. + */ + public function socialTemplates(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\Template::class); + } + + /** + * Get social media files for this workspace. + */ + public function socialMedia(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\Media::class); + } + + /** + * Get social hashtag groups for this workspace. + */ + public function socialHashtagGroups(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\HashtagGroup::class); + } + + /** + * Get social webhooks for this workspace. + */ + public function socialWebhooks(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\Webhook::class); + } + + /** + * Get social analytics for this workspace. + */ + public function socialAnalytics(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\Analytics::class); + } + + /** + * Get social variables for this workspace. + */ + public function socialVariables(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\Variable::class); + } + + /** + * Get posting schedule for this workspace. + */ + public function socialPostingSchedule(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\PostingSchedule::class); + } + + /** + * Get imported posts for this workspace. + */ + public function socialImportedPosts(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\ImportedPost::class); + } + + /** + * Get social metrics for this workspace. + */ + public function socialMetrics(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\Metric::class); + } + + /** + * Get audience data for this workspace. + */ + public function socialAudience(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\Audience::class); + } + + /** + * Get Facebook insights for this workspace. + */ + public function socialFacebookInsights(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\FacebookInsight::class); + } + + /** + * Get Instagram insights for this workspace. + */ + public function socialInstagramInsights(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\InstagramInsight::class); + } + + /** + * Get Pinterest analytics for this workspace. + */ + public function socialPinterestAnalytics(): HasMany + { + return $this->hasMany(\Core\Mod\Social\Models\PinterestAnalytic::class); + } + + /** + * Check if this workspace has SocialHost enabled (has connected social accounts). + */ + public function hasSocialHost(): bool + { + return $this->socialAccounts()->exists(); + } + + /** + * Get count of connected social accounts. + */ + public function socialAccountsCount(): int + { + return $this->socialAccounts()->count(); + } + + // NOTE: Bio service relationships (bioPages, bioProjects, bioDomains, bioPixels) + // have been moved to the Host UK app's Mod\Bio module. + + // AnalyticsHost Relationships + + /** + * Get analytics websites for this workspace (AnalyticsHost). + */ + public function analyticsSites(): HasMany + { + return $this->hasMany(\Core\Mod\Analytics\Models\Website::class); + } + + /** + * Get social analytics websites for this workspace (legacy, for SocialHost analytics). + */ + public function socialAnalyticsWebsites(): HasMany + { + return $this->hasMany(\Core\Mod\Analytics\Models\AnalyticsWebsite::class); + } + + /** + * Get analytics goals for this workspace (AnalyticsHost). + */ + public function analyticsGoals(): HasMany + { + return $this->hasMany(\Core\Mod\Analytics\Models\Goal::class); + } + + /** + * Get social analytics goals for this workspace (legacy, for SocialHost analytics). + */ + public function socialAnalyticsGoals(): HasMany + { + return $this->hasMany(\Core\Mod\Analytics\Models\AnalyticsGoal::class); + } + + // TrustHost Relationships + + /** + * Get social proof campaigns (TrustHost widgets) for this workspace. + */ + public function trustWidgets(): HasMany + { + return $this->hasMany(\Core\Mod\Trust\Models\Campaign::class); + } + + /** + * Get social proof notifications for this workspace. + */ + public function trustNotifications(): HasMany + { + return $this->hasMany(\Core\Mod\Trust\Models\Notification::class); + } + + // NotifyHost Relationships + + /** + * Get push notification websites for this workspace. + */ + public function notificationSites(): HasMany + { + return $this->hasMany(\Core\Mod\Notify\Models\PushWebsite::class); + } + + /** + * Get push campaigns for this workspace. + */ + public function pushCampaigns(): HasMany + { + return $this->hasMany(\Core\Mod\Notify\Models\PushCampaign::class); + } + + /** + * Get push flows for this workspace. + */ + public function pushFlows(): HasMany + { + return $this->hasMany(\Core\Mod\Notify\Models\PushFlow::class); + } + + /** + * Get push segments for this workspace. + */ + public function pushSegments(): HasMany + { + return $this->hasMany(\Core\Mod\Notify\Models\PushSegment::class); + } + + // API & Webhooks Relationships + + /** + * Get API keys for this workspace. + */ + public function apiKeys(): HasMany + { + return $this->hasMany(\Core\Mod\Api\Models\ApiKey::class); + } + + /** + * Get webhook endpoints for this workspace. + */ + public function webhookEndpoints(): HasMany + { + return $this->hasMany(\Core\Mod\Api\Models\WebhookEndpoint::class); + } + + /** + * Get entitlement webhooks for this workspace. + */ + public function entitlementWebhooks(): HasMany + { + return $this->hasMany(EntitlementWebhook::class); + } + + // Trees for Agents Relationships + + /** + * Get tree plantings for this workspace. + */ + public function treePlantings(): HasMany + { + return $this->hasMany(\Core\Mod\Trees\Models\TreePlanting::class); + } + + /** + * Get total trees planted for this workspace. + */ + public function treesPlanted(): int + { + return $this->treePlantings() + ->whereIn('status', ['confirmed', 'planted']) + ->sum('trees'); + } + + /** + * Get trees planted this year for this workspace. + */ + public function treesThisYear(): int + { + return $this->treePlantings() + ->whereIn('status', ['confirmed', 'planted']) + ->whereYear('created_at', now()->year) + ->sum('trees'); + } + + // Content & Media Relationships + + /** + * Get content items for this workspace. + */ + public function contentItems(): HasMany + { + return $this->hasMany(\Core\Mod\Content\Models\ContentItem::class); + } + + /** + * Get content authors for this workspace. + */ + public function contentAuthors(): HasMany + { + return $this->hasMany(\Core\Mod\Content\Models\ContentAuthor::class); + } + + // Commerce Relationships (defined in app Mod\Commerce) + + /** + * Get subscriptions for this workspace. + */ + public function subscriptions(): HasMany + { + return $this->hasMany(\Mod\Commerce\Models\Subscription::class); + } + + /** + * Get invoices for this workspace. + */ + public function invoices(): HasMany + { + return $this->hasMany(\Mod\Commerce\Models\Invoice::class); + } + + /** + * Get payment methods for this workspace. + */ + public function paymentMethods(): HasMany + { + return $this->hasMany(\Mod\Commerce\Models\PaymentMethod::class); + } + + /** + * Get orders for this workspace. + */ + public function orders(): MorphMany + { + return $this->morphMany(\Mod\Commerce\Models\Order::class, 'orderable'); + } + + // Helper Methods + + /** + * Get the currently active workspace from request context. + * + * Returns the Workspace model instance (not array). + */ + public static function current(): ?self + { + // Try to get from request attributes (set by middleware) + if (request()->attributes->has('workspace_model')) { + return request()->attributes->get('workspace_model'); + } + + // Try to get from authenticated user's default workspace + if (auth()->check() && auth()->user() instanceof \Core\Mod\Tenant\Models\User) { + return auth()->user()->defaultHostWorkspace(); + } + + // Try to resolve from subdomain via WorkspaceService + $workspaceService = app(\App\Services\WorkspaceService::class); + $slug = $workspaceService->currentSlug(); + + return static::where('slug', $slug)->first(); + } + + /** + * Check if workspace can use a feature. + */ + public function can(string $featureCode, int $quantity = 1): EntitlementResult + { + return app(EntitlementService::class)->can($this, $featureCode, $quantity); + } + + /** + * Record usage of a feature. + */ + public function recordUsage(string $featureCode, int $quantity = 1, ?User $user = null, ?array $metadata = null): UsageRecord + { + return app(EntitlementService::class)->recordUsage($this, $featureCode, $quantity, $user, $metadata); + } + + /** + * Get usage summary for all features. + */ + public function getUsageSummary(): \Illuminate\Support\Collection + { + return app(EntitlementService::class)->getUsageSummary($this); + } + + /** + * Check if workspace has a specific package. + */ + public function hasPackage(string $packageCode): bool + { + return $this->workspacePackages() + ->whereHas('package', fn ($q) => $q->where('code', $packageCode)) + ->active() + ->exists(); + } + + /** + * Check if workspace has Apollo tier. + */ + public function isApollo(): bool + { + return $this->can('tier.apollo')->isAllowed(); + } + + /** + * Check if workspace has Hades tier. + */ + public function isHades(): bool + { + return $this->can('tier.hades')->isAllowed(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Workspace Invitations + // ───────────────────────────────────────────────────────────────────────── + + /** + * Invite a user to this workspace by email. + * + * @param string $email The email address to invite + * @param string $role The role to assign (owner, admin, member) + * @param User|null $invitedBy The user sending the invitation + * @param int $expiresInDays Number of days until invitation expires + */ + public function invite(string $email, string $role = 'member', ?User $invitedBy = null, int $expiresInDays = 7): WorkspaceInvitation + { + // Check if there's already a pending invitation for this email + $existing = $this->invitations() + ->where('email', $email) + ->pending() + ->first(); + + if ($existing) { + // Update existing invitation + $existing->update([ + 'role' => $role, + 'invited_by' => $invitedBy?->id, + 'expires_at' => now()->addDays($expiresInDays), + ]); + + return $existing; + } + + // Create new invitation + $invitation = $this->invitations()->create([ + 'email' => $email, + 'token' => WorkspaceInvitation::generateToken(), + 'role' => $role, + 'invited_by' => $invitedBy?->id, + 'expires_at' => now()->addDays($expiresInDays), + ]); + + // Send notification + $invitation->notify(new \Core\Mod\Tenant\Notifications\WorkspaceInvitationNotification($invitation)); + + return $invitation; + } + + /** + * Accept an invitation to this workspace using a token. + * + * @param string $token The invitation token + * @param User $user The user accepting the invitation + * @return bool True if accepted, false if invalid/expired + */ + public static function acceptInvitation(string $token, User $user): bool + { + $invitation = WorkspaceInvitation::findPendingByToken($token); + + if (! $invitation) { + return false; + } + + return $invitation->accept($user); + } + + /** + * Get the external CMS URL for this workspace. + */ + public function getCmsUrlAttribute(): string + { + return 'https://'.$this->domain; + } + + /** + * Scope to only active workspaces. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope to order by sort order. + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort_order'); + } + + /** + * Convert to array format used by WorkspaceService. + */ + public function toServiceArray(): array + { + return [ + 'name' => $this->name, + 'slug' => $this->slug, + 'domain' => $this->domain, + 'icon' => $this->icon, + 'color' => $this->color, + 'description' => $this->description, + ]; + } + + /** + * Generate a new webhook secret for the WP connector. + */ + public function generateWpConnectorSecret(): string + { + $secret = bin2hex(random_bytes(32)); + $this->update(['wp_connector_secret' => $secret]); + + return $secret; + } + + /** + * Enable the WP connector with a URL. + */ + public function enableWpConnector(string $url): self + { + $this->update([ + 'wp_connector_enabled' => true, + 'wp_connector_url' => rtrim($url, '/'), + 'wp_connector_secret' => $this->wp_connector_secret ?? bin2hex(random_bytes(32)), + ]); + + return $this; + } + + /** + * Disable the WP connector. + */ + public function disableWpConnector(): self + { + $this->update([ + 'wp_connector_enabled' => false, + 'wp_connector_verified_at' => null, + ]); + + return $this; + } + + /** + * Mark the WP connector as verified. + */ + public function markWpConnectorVerified(): self + { + $this->update(['wp_connector_verified_at' => now()]); + + return $this; + } + + /** + * Update the last sync timestamp. + */ + public function touchWpConnectorSync(): self + { + $this->update(['wp_connector_last_sync' => now()]); + + return $this; + } + + /** + * Check if the WP connector is active and verified. + */ + public function hasActiveWpConnector(): bool + { + return $this->wp_connector_enabled + && ! empty($this->wp_connector_url) + && ! empty($this->wp_connector_secret); + } + + /** + * Get the webhook URL that external CMS should POST to. + */ + public function getWpConnectorWebhookUrlAttribute(): string + { + return route('api.webhook.content').'?workspace='.$this->slug; + } + + /** + * Validate an incoming webhook signature. + */ + public function validateWebhookSignature(string $payload, string $signature): bool + { + if (empty($this->wp_connector_secret)) { + return false; + } + + $expected = hash_hmac('sha256', $payload, $this->wp_connector_secret); + + return hash_equals($expected, $signature); + } +} diff --git a/src/Models/WorkspaceInvitation.php b/src/Models/WorkspaceInvitation.php new file mode 100644 index 0000000..a863a82 --- /dev/null +++ b/src/Models/WorkspaceInvitation.php @@ -0,0 +1,168 @@ + 'datetime', + 'accepted_at' => 'datetime', + ]; + + /** + * Get the workspace this invitation is for. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * Get the user who sent this invitation. + */ + public function inviter(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by'); + } + + /** + * Scope to pending invitations (not accepted, not expired). + */ + public function scopePending($query) + { + return $query->whereNull('accepted_at') + ->where('expires_at', '>', now()); + } + + /** + * Scope to expired invitations. + */ + public function scopeExpired($query) + { + return $query->whereNull('accepted_at') + ->where('expires_at', '<=', now()); + } + + /** + * Scope to accepted invitations. + */ + public function scopeAccepted($query) + { + return $query->whereNotNull('accepted_at'); + } + + /** + * Check if invitation is pending (not accepted and not expired). + */ + public function isPending(): bool + { + return $this->accepted_at === null && $this->expires_at->isFuture(); + } + + /** + * Check if invitation has expired. + */ + public function isExpired(): bool + { + return $this->accepted_at === null && $this->expires_at->isPast(); + } + + /** + * Check if invitation has been accepted. + */ + public function isAccepted(): bool + { + return $this->accepted_at !== null; + } + + /** + * Generate a unique token for this invitation. + */ + public static function generateToken(): string + { + do { + $token = Str::random(64); + } while (static::where('token', $token)->exists()); + + return $token; + } + + /** + * Find invitation by token. + */ + public static function findByToken(string $token): ?self + { + return static::where('token', $token)->first(); + } + + /** + * Find pending invitation by token. + */ + public static function findPendingByToken(string $token): ?self + { + return static::where('token', $token)->pending()->first(); + } + + /** + * Accept the invitation for a user. + */ + public function accept(User $user): bool + { + if (! $this->isPending()) { + return false; + } + + // Check if user already belongs to this workspace + if ($this->workspace->users()->where('user_id', $user->id)->exists()) { + // Mark as accepted but don't add again + $this->update(['accepted_at' => now()]); + + return true; + } + + // Add user to workspace with the invited role + $this->workspace->users()->attach($user->id, [ + 'role' => $this->role, + 'is_default' => false, + ]); + + // Mark invitation as accepted + $this->update(['accepted_at' => now()]); + + return true; + } + + /** + * Get the notification routing for mail. + */ + public function routeNotificationForMail(): string + { + return $this->email; + } +} diff --git a/src/Models/WorkspaceMember.php b/src/Models/WorkspaceMember.php new file mode 100644 index 0000000..6d49df7 --- /dev/null +++ b/src/Models/WorkspaceMember.php @@ -0,0 +1,377 @@ + 'array', + 'is_default' => 'boolean', + 'joined_at' => 'datetime', + ]; + + // ───────────────────────────────────────────────────────────────────────── + // Role Constants (legacy, for backwards compatibility) + // ───────────────────────────────────────────────────────────────────────── + + public const ROLE_OWNER = 'owner'; + + public const ROLE_ADMIN = 'admin'; + + public const ROLE_MEMBER = 'member'; + + // ───────────────────────────────────────────────────────────────────────── + // Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get the user for this membership. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the workspace for this membership. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * Get the team for this membership. + */ + public function team(): BelongsTo + { + return $this->belongsTo(WorkspaceTeam::class, 'team_id'); + } + + /** + * Get the user who invited this member. + */ + public function inviter(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by'); + } + + // ───────────────────────────────────────────────────────────────────────── + // Scopes + // ───────────────────────────────────────────────────────────────────────── + + /** + * Scope to a specific workspace. + */ + public function scopeForWorkspace($query, Workspace|int $workspace) + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return $query->where('workspace_id', $workspaceId); + } + + /** + * Scope to a specific user. + */ + public function scopeForUser($query, User|int $user) + { + $userId = $user instanceof User ? $user->id : $user; + + return $query->where('user_id', $userId); + } + + /** + * Scope to members with a specific role. + */ + public function scopeWithRole($query, string $role) + { + return $query->where('role', $role); + } + + /** + * Scope to members in a specific team. + */ + public function scopeInTeam($query, WorkspaceTeam|int $team) + { + $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; + + return $query->where('team_id', $teamId); + } + + /** + * Scope to owners only. + */ + public function scopeOwners($query) + { + return $query->where('role', self::ROLE_OWNER); + } + + // ───────────────────────────────────────────────────────────────────────── + // Permission Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all effective permissions for this member. + * + * Merges team permissions with custom permission overrides. + */ + public function getEffectivePermissions(): array + { + // Start with team permissions + $permissions = $this->team?->permissions ?? []; + + // Merge custom permissions (overrides) + $customPermissions = $this->custom_permissions ?? []; + + // Custom permissions can grant (+permission) or revoke (-permission) + foreach ($customPermissions as $permission) { + if (str_starts_with($permission, '-')) { + // Remove permission + $toRemove = substr($permission, 1); + $permissions = array_values(array_filter( + $permissions, + fn ($p) => $p !== $toRemove + )); + } elseif (str_starts_with($permission, '+')) { + // Add permission (explicit add) + $toAdd = substr($permission, 1); + if (! in_array($toAdd, $permissions, true)) { + $permissions[] = $toAdd; + } + } else { + // Treat as add if no prefix + if (! in_array($permission, $permissions, true)) { + $permissions[] = $permission; + } + } + } + + // Legacy fallback: if no team, derive from role + if (! $this->team_id) { + $rolePermissions = match ($this->role) { + self::ROLE_OWNER => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_OWNER), + self::ROLE_ADMIN => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_ADMIN), + default => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_MEMBER), + }; + $permissions = array_unique(array_merge($permissions, $rolePermissions)); + } + + return array_values(array_unique($permissions)); + } + + /** + * Check if this member has a specific permission. + */ + public function hasPermission(string $permission): bool + { + $permissions = $this->getEffectivePermissions(); + + // Check for exact match + if (in_array($permission, $permissions, true)) { + return true; + } + + // Check for wildcard permissions + foreach ($permissions as $perm) { + if (str_ends_with($perm, '.*')) { + $prefix = substr($perm, 0, -1); + if (str_starts_with($permission, $prefix)) { + return true; + } + } + } + + return false; + } + + /** + * Check if this member has any of the given permissions. + */ + public function hasAnyPermission(array $permissions): bool + { + foreach ($permissions as $permission) { + if ($this->hasPermission($permission)) { + return true; + } + } + + return false; + } + + /** + * Check if this member has all of the given permissions. + */ + public function hasAllPermissions(array $permissions): bool + { + foreach ($permissions as $permission) { + if (! $this->hasPermission($permission)) { + return false; + } + } + + return true; + } + + /** + * Add a custom permission override. + */ + public function grantCustomPermission(string $permission): self + { + $custom = $this->custom_permissions ?? []; + + // Remove any revocation of this permission + $custom = array_filter($custom, fn ($p) => $p !== '-'.$permission); + + // Add the permission if not already present + if (! in_array($permission, $custom, true) && ! in_array('+'.$permission, $custom, true)) { + $custom[] = '+'.$permission; + } + + $this->update(['custom_permissions' => array_values($custom)]); + + return $this; + } + + /** + * Revoke a permission via custom override. + */ + public function revokeCustomPermission(string $permission): self + { + $custom = $this->custom_permissions ?? []; + + // Remove any grant of this permission + $custom = array_filter($custom, fn ($p) => $p !== $permission && $p !== '+'.$permission); + + // Add revocation + if (! in_array('-'.$permission, $custom, true)) { + $custom[] = '-'.$permission; + } + + $this->update(['custom_permissions' => array_values($custom)]); + + return $this; + } + + /** + * Clear all custom permission overrides. + */ + public function clearCustomPermissions(): self + { + $this->update(['custom_permissions' => null]); + + return $this; + } + + // ───────────────────────────────────────────────────────────────────────── + // Helper Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Check if this member is the workspace owner. + */ + public function isOwner(): bool + { + return $this->role === self::ROLE_OWNER + || $this->team?->slug === WorkspaceTeam::TEAM_OWNER; + } + + /** + * Check if this member is an admin. + */ + public function isAdmin(): bool + { + return $this->isOwner() + || $this->role === self::ROLE_ADMIN + || $this->team?->slug === WorkspaceTeam::TEAM_ADMIN; + } + + /** + * Assign this member to a team. + */ + public function assignToTeam(WorkspaceTeam|int $team): self + { + $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; + + $this->update(['team_id' => $teamId]); + + return $this; + } + + /** + * Remove this member from their team. + */ + public function removeFromTeam(): self + { + $this->update(['team_id' => null]); + + return $this; + } + + /** + * Get the display name for this membership (team name or role). + */ + public function getDisplayRole(): string + { + if ($this->team) { + return $this->team->name; + } + + return match ($this->role) { + self::ROLE_OWNER => 'Owner', + self::ROLE_ADMIN => 'Admin', + default => 'Member', + }; + } + + /** + * Get the colour for this membership's role badge. + */ + public function getRoleColour(): string + { + if ($this->team) { + return $this->team->colour; + } + + return match ($this->role) { + self::ROLE_OWNER => 'violet', + self::ROLE_ADMIN => 'blue', + default => 'zinc', + }; + } +} diff --git a/src/Models/WorkspacePackage.php b/src/Models/WorkspacePackage.php new file mode 100644 index 0000000..629073b --- /dev/null +++ b/src/Models/WorkspacePackage.php @@ -0,0 +1,164 @@ + 'datetime', + 'expires_at' => 'datetime', + 'billing_cycle_anchor' => 'datetime', + 'metadata' => 'array', + ]; + + /** + * Status constants. + */ + public const STATUS_ACTIVE = 'active'; + + public const STATUS_SUSPENDED = 'suspended'; + + public const STATUS_CANCELLED = 'cancelled'; + + public const STATUS_EXPIRED = 'expired'; + + /** + * The workspace this package belongs to. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * The package definition. + */ + public function package(): BelongsTo + { + return $this->belongsTo(Package::class, 'package_id'); + } + + /** + * Scope to active assignments. + */ + public function scopeActive($query) + { + return $query->where('status', self::STATUS_ACTIVE); + } + + /** + * Scope to non-expired assignments. + */ + public function scopeNotExpired($query) + { + return $query->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } + + /** + * Check if this assignment is currently active. + */ + public function isActive(): bool + { + if ($this->status !== self::STATUS_ACTIVE) { + return false; + } + + if ($this->starts_at && $this->starts_at->isFuture()) { + return false; + } + + if ($this->expires_at && $this->expires_at->isPast()) { + return false; + } + + return true; + } + + /** + * Check if this assignment is on grace period. + */ + public function onGracePeriod(): bool + { + return $this->status === self::STATUS_CANCELLED + && $this->expires_at + && $this->expires_at->isFuture(); + } + + /** + * Get the current billing cycle start date. + */ + public function getCurrentCycleStart(): Carbon + { + if (! $this->billing_cycle_anchor) { + return $this->starts_at ?? $this->created_at; + } + + $anchor = $this->billing_cycle_anchor->copy(); + $now = now(); + + // Find the most recent cycle start + while ($anchor->addMonth()->lte($now)) { + // Keep advancing until we pass now + } + + return $anchor->subMonth(); + } + + /** + * Get the current billing cycle end date. + */ + public function getCurrentCycleEnd(): Carbon + { + return $this->getCurrentCycleStart()->copy()->addMonth(); + } + + /** + * Suspend this assignment. + */ + public function suspend(): void + { + $this->update(['status' => self::STATUS_SUSPENDED]); + } + + /** + * Reactivate this assignment. + */ + public function reactivate(): void + { + $this->update(['status' => self::STATUS_ACTIVE]); + } + + /** + * Cancel this assignment. + */ + public function cancel(?Carbon $endsAt = null): void + { + $this->update([ + 'status' => self::STATUS_CANCELLED, + 'expires_at' => $endsAt ?? $this->getCurrentCycleEnd(), + ]); + } +} diff --git a/src/Models/WorkspaceTeam.php b/src/Models/WorkspaceTeam.php new file mode 100644 index 0000000..7e11644 --- /dev/null +++ b/src/Models/WorkspaceTeam.php @@ -0,0 +1,517 @@ + 'array', + 'is_default' => 'boolean', + 'is_system' => 'boolean', + 'sort_order' => 'integer', + ]; + + // ───────────────────────────────────────────────────────────────────────── + // Boot + // ───────────────────────────────────────────────────────────────────────── + + protected static function boot(): void + { + parent::boot(); + + static::creating(function (self $team) { + if (empty($team->slug)) { + $team->slug = Str::slug($team->name); + } + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get members assigned to this team via the pivot. + */ + public function members(): HasMany + { + return $this->hasMany(WorkspaceMember::class, 'team_id'); + } + + // ───────────────────────────────────────────────────────────────────────── + // Scopes + // ───────────────────────────────────────────────────────────────────────── + + /** + * Scope to default teams only. + */ + public function scopeDefault($query) + { + return $query->where('is_default', true); + } + + /** + * Scope to system teams only. + */ + public function scopeSystem($query) + { + return $query->where('is_system', true); + } + + /** + * Scope to custom (non-system) teams only. + */ + public function scopeCustom($query) + { + return $query->where('is_system', false); + } + + /** + * Scope ordered by sort_order. + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort_order'); + } + + // ───────────────────────────────────────────────────────────────────────── + // Permission Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Check if this team has a specific permission. + */ + public function hasPermission(string $permission): bool + { + $permissions = $this->permissions ?? []; + + // Check for exact match + if (in_array($permission, $permissions, true)) { + return true; + } + + // Check for wildcard permissions (e.g., 'bio.*' matches 'bio.read') + foreach ($permissions as $perm) { + if (str_ends_with($perm, '.*')) { + $prefix = substr($perm, 0, -1); // Remove the '*' + if (str_starts_with($permission, $prefix)) { + return true; + } + } + } + + return false; + } + + /** + * Check if this team has any of the given permissions. + */ + public function hasAnyPermission(array $permissions): bool + { + foreach ($permissions as $permission) { + if ($this->hasPermission($permission)) { + return true; + } + } + + return false; + } + + /** + * Check if this team has all of the given permissions. + */ + public function hasAllPermissions(array $permissions): bool + { + foreach ($permissions as $permission) { + if (! $this->hasPermission($permission)) { + return false; + } + } + + return true; + } + + /** + * Grant a permission to this team. + */ + public function grantPermission(string $permission): self + { + $permissions = $this->permissions ?? []; + + if (! in_array($permission, $permissions, true)) { + $permissions[] = $permission; + $this->update(['permissions' => $permissions]); + } + + return $this; + } + + /** + * Revoke a permission from this team. + */ + public function revokePermission(string $permission): self + { + $permissions = $this->permissions ?? []; + $permissions = array_values(array_filter($permissions, fn ($p) => $p !== $permission)); + + $this->update(['permissions' => $permissions]); + + return $this; + } + + /** + * Set all permissions for this team. + */ + public function setPermissions(array $permissions): self + { + $this->update(['permissions' => $permissions]); + + return $this; + } + + // ───────────────────────────────────────────────────────────────────────── + // Static Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all available permissions grouped by category. + */ + public static function getAvailablePermissions(): array + { + return [ + 'workspace' => [ + 'label' => 'Workspace', + 'permissions' => [ + self::PERM_WORKSPACE_SETTINGS => 'Manage settings', + self::PERM_WORKSPACE_MEMBERS => 'Manage members', + self::PERM_WORKSPACE_TEAMS => 'Manage teams', + self::PERM_WORKSPACE_BILLING => 'Manage billing', + self::PERM_WORKSPACE_DELETE => 'Delete workspace', + ], + ], + 'bio' => [ + 'label' => 'BioHost', + 'permissions' => [ + self::PERM_BIO_READ => 'View pages', + self::PERM_BIO_WRITE => 'Create and edit pages', + self::PERM_BIO_ADMIN => 'Full access', + ], + ], + 'social' => [ + 'label' => 'SocialHost', + 'permissions' => [ + self::PERM_SOCIAL_READ => 'View posts and accounts', + self::PERM_SOCIAL_WRITE => 'Create and edit posts', + self::PERM_SOCIAL_ADMIN => 'Full access', + ], + ], + 'analytics' => [ + 'label' => 'AnalyticsHost', + 'permissions' => [ + self::PERM_ANALYTICS_READ => 'View analytics', + self::PERM_ANALYTICS_WRITE => 'Configure tracking', + self::PERM_ANALYTICS_ADMIN => 'Full access', + ], + ], + 'trust' => [ + 'label' => 'TrustHost', + 'permissions' => [ + self::PERM_TRUST_READ => 'View campaigns', + self::PERM_TRUST_WRITE => 'Create and edit campaigns', + self::PERM_TRUST_ADMIN => 'Full access', + ], + ], + 'notify' => [ + 'label' => 'NotifyHost', + 'permissions' => [ + self::PERM_NOTIFY_READ => 'View notifications', + self::PERM_NOTIFY_WRITE => 'Send notifications', + self::PERM_NOTIFY_ADMIN => 'Full access', + ], + ], + 'support' => [ + 'label' => 'SupportHost', + 'permissions' => [ + self::PERM_SUPPORT_READ => 'View conversations', + self::PERM_SUPPORT_WRITE => 'Reply to conversations', + self::PERM_SUPPORT_ADMIN => 'Full access', + ], + ], + 'commerce' => [ + 'label' => 'Commerce', + 'permissions' => [ + self::PERM_COMMERCE_READ => 'View orders and invoices', + self::PERM_COMMERCE_WRITE => 'Manage orders', + self::PERM_COMMERCE_ADMIN => 'Full access', + ], + ], + 'api' => [ + 'label' => 'API', + 'permissions' => [ + self::PERM_API_READ => 'View API keys', + self::PERM_API_WRITE => 'Create API keys', + self::PERM_API_ADMIN => 'Full access', + ], + ], + ]; + } + + /** + * Get flat list of all permission keys. + */ + public static function getAllPermissionKeys(): array + { + $keys = []; + foreach (self::getAvailablePermissions() as $group) { + $keys = array_merge($keys, array_keys($group['permissions'])); + } + + return $keys; + } + + /** + * Get default permissions for a given team type. + */ + public static function getDefaultPermissionsFor(string $teamSlug): array + { + return match ($teamSlug) { + self::TEAM_OWNER => self::getAllPermissionKeys(), // Owner gets all permissions + self::TEAM_ADMIN => array_filter( + self::getAllPermissionKeys(), + fn ($p) => ! in_array($p, [ + self::PERM_WORKSPACE_DELETE, + self::PERM_WORKSPACE_BILLING, + ], true) + ), + self::TEAM_MEMBER => [ + self::PERM_BIO_READ, + self::PERM_BIO_WRITE, + self::PERM_SOCIAL_READ, + self::PERM_SOCIAL_WRITE, + self::PERM_ANALYTICS_READ, + self::PERM_TRUST_READ, + self::PERM_TRUST_WRITE, + self::PERM_NOTIFY_READ, + self::PERM_NOTIFY_WRITE, + self::PERM_SUPPORT_READ, + self::PERM_SUPPORT_WRITE, + self::PERM_COMMERCE_READ, + self::PERM_API_READ, + ], + self::TEAM_VIEWER => [ + self::PERM_BIO_READ, + self::PERM_SOCIAL_READ, + self::PERM_ANALYTICS_READ, + self::PERM_TRUST_READ, + self::PERM_NOTIFY_READ, + self::PERM_SUPPORT_READ, + self::PERM_COMMERCE_READ, + self::PERM_API_READ, + ], + default => [], + }; + } + + /** + * Get the default team definitions for seeding. + */ + public static function getDefaultTeamDefinitions(): array + { + return [ + [ + 'name' => 'Owner', + 'slug' => self::TEAM_OWNER, + 'description' => 'Full ownership access to the workspace.', + 'permissions' => self::getDefaultPermissionsFor(self::TEAM_OWNER), + 'is_system' => true, + 'colour' => 'violet', + 'sort_order' => 1, + ], + [ + 'name' => 'Admin', + 'slug' => self::TEAM_ADMIN, + 'description' => 'Administrative access without billing or deletion rights.', + 'permissions' => self::getDefaultPermissionsFor(self::TEAM_ADMIN), + 'is_system' => true, + 'colour' => 'blue', + 'sort_order' => 2, + ], + [ + 'name' => 'Member', + 'slug' => self::TEAM_MEMBER, + 'description' => 'Standard member access to create and edit content.', + 'permissions' => self::getDefaultPermissionsFor(self::TEAM_MEMBER), + 'is_system' => true, + 'is_default' => true, + 'colour' => 'emerald', + 'sort_order' => 3, + ], + [ + 'name' => 'Viewer', + 'slug' => self::TEAM_VIEWER, + 'description' => 'Read-only access to view content.', + 'permissions' => self::getDefaultPermissionsFor(self::TEAM_VIEWER), + 'is_system' => true, + 'colour' => 'zinc', + 'sort_order' => 4, + ], + ]; + } + + /** + * Get available colour options for teams. + */ + public static function getColourOptions(): array + { + return [ + 'zinc' => 'Grey', + 'red' => 'Red', + 'orange' => 'Orange', + 'amber' => 'Amber', + 'yellow' => 'Yellow', + 'lime' => 'Lime', + 'green' => 'Green', + 'emerald' => 'Emerald', + 'teal' => 'Teal', + 'cyan' => 'Cyan', + 'sky' => 'Sky', + 'blue' => 'Blue', + 'indigo' => 'Indigo', + 'violet' => 'Violet', + 'purple' => 'Purple', + 'fuchsia' => 'Fuchsia', + 'pink' => 'Pink', + 'rose' => 'Rose', + ]; + } +} diff --git a/src/Notifications/BoostExpiredNotification.php b/src/Notifications/BoostExpiredNotification.php new file mode 100644 index 0000000..17862c9 --- /dev/null +++ b/src/Notifications/BoostExpiredNotification.php @@ -0,0 +1,144 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $workspaceName = $this->workspace->name; + $appName = config('core.app.name', 'Host UK'); + $boostCount = $this->expiredBoosts->count(); + + $message = (new MailMessage) + ->subject($this->getSubject($boostCount, $workspaceName)) + ->greeting('Hi,'); + + if ($boostCount === 1) { + $boost = $this->expiredBoosts->first(); + $featureName = $this->getFeatureName($boost->feature_code); + + return $message + ->line("A boost for **{$featureName}** has expired in your **{$workspaceName}** workspace.") + ->line('This was a cycle-bound boost that ended with your billing period.') + ->line($this->getBoostDescription($boost)) + ->action('View Usage', route('hub.billing')) + ->line('You can purchase additional boosts or upgrade your plan to restore this capacity.') + ->salutation("Cheers, the {$appName} team"); + } + + // Multiple boosts expired + $message->line("The following boosts have expired in your **{$workspaceName}** workspace:"); + + foreach ($this->expiredBoosts as $boost) { + $featureName = $this->getFeatureName($boost->feature_code); + $message->line("- **{$featureName}**: {$this->getBoostDescription($boost)}"); + } + + return $message + ->line('These were cycle-bound boosts that ended with your billing period.') + ->action('View Usage', route('hub.billing')) + ->line('You can purchase additional boosts or upgrade your plan to restore this capacity.') + ->salutation("Cheers, the {$appName} team"); + } + + /** + * Get email subject. + */ + protected function getSubject(int $boostCount, string $workspaceName): string + { + if ($boostCount === 1) { + $boost = $this->expiredBoosts->first(); + $featureName = $this->getFeatureName($boost->feature_code); + + return "{$featureName} boost expired - {$workspaceName}"; + } + + return "{$boostCount} boosts expired - {$workspaceName}"; + } + + /** + * Get the feature name for a code. + */ + protected function getFeatureName(string $featureCode): string + { + $feature = Feature::where('code', $featureCode)->first(); + + return $feature?->name ?? ucwords(str_replace(['.', '_', '-'], ' ', $featureCode)); + } + + /** + * Get description of what the boost provided. + */ + protected function getBoostDescription(Boost $boost): string + { + if ($boost->boost_type === Boost::BOOST_TYPE_UNLIMITED) { + return 'Unlimited access'; + } + + if ($boost->boost_type === Boost::BOOST_TYPE_ENABLE) { + return 'Feature access'; + } + + $consumed = $boost->consumed_quantity ?? 0; + $total = $boost->limit_value ?? 0; + + return "+{$total} capacity ({$consumed} used)"; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'boost_expired', + 'workspace_id' => $this->workspace->id, + 'workspace_name' => $this->workspace->name, + 'boosts' => $this->expiredBoosts->map(fn ($boost) => [ + 'id' => $boost->id, + 'feature_code' => $boost->feature_code, + 'boost_type' => $boost->boost_type, + 'limit_value' => $boost->limit_value, + 'consumed_quantity' => $boost->consumed_quantity, + ])->toArray(), + 'count' => $this->expiredBoosts->count(), + ]; + } +} diff --git a/src/Notifications/UsageAlertNotification.php b/src/Notifications/UsageAlertNotification.php new file mode 100644 index 0000000..6737bc2 --- /dev/null +++ b/src/Notifications/UsageAlertNotification.php @@ -0,0 +1,162 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $percentage = round(($this->used / $this->limit) * 100); + $remaining = max(0, $this->limit - $this->used); + $featureName = $this->feature->name; + $workspaceName = $this->workspace->name; + $appName = config('core.app.name', 'Host UK'); + + $message = (new MailMessage) + ->subject($this->getSubject($featureName, $percentage)); + + if ($this->threshold === UsageAlertHistory::THRESHOLD_LIMIT) { + return $this->limitReachedEmail($message, $featureName, $workspaceName, $appName); + } + + if ($this->threshold === UsageAlertHistory::THRESHOLD_CRITICAL) { + return $this->criticalEmail($message, $featureName, $workspaceName, $percentage, $remaining, $appName); + } + + return $this->warningEmail($message, $featureName, $workspaceName, $percentage, $remaining, $appName); + } + + /** + * Get email subject based on threshold. + */ + protected function getSubject(string $featureName, int $percentage): string + { + if ($this->threshold === UsageAlertHistory::THRESHOLD_LIMIT) { + return "{$featureName} limit reached"; + } + + return "{$featureName} usage at {$percentage}%"; + } + + /** + * Build warning email (80% threshold). + */ + protected function warningEmail( + MailMessage $message, + string $featureName, + string $workspaceName, + int $percentage, + int $remaining, + string $appName + ): MailMessage { + return $message + ->greeting('Hi,') + ->line("Your **{$workspaceName}** workspace is approaching its **{$featureName}** limit.") + ->line("**Current usage:** {$this->used} of {$this->limit} ({$percentage}%)") + ->line("**Remaining:** {$remaining}") + ->line('Consider upgrading your plan to ensure uninterrupted service.') + ->action('View Usage', route('hub.billing')) + ->line('If you have questions about your plan, please contact our support team.') + ->salutation("Cheers, the {$appName} team"); + } + + /** + * Build critical email (90% threshold). + */ + protected function criticalEmail( + MailMessage $message, + string $featureName, + string $workspaceName, + int $percentage, + int $remaining, + string $appName + ): MailMessage { + return $message + ->greeting('Hi,') + ->line("**Urgent:** Your **{$workspaceName}** workspace is almost at its **{$featureName}** limit.") + ->line("**Current usage:** {$this->used} of {$this->limit} ({$percentage}%)") + ->line("**Only {$remaining} remaining**") + ->line('Upgrade now to avoid any service interruptions.') + ->action('Upgrade Plan', route('hub.billing')) + ->line('Need help? Contact our support team.') + ->salutation("Cheers, the {$appName} team"); + } + + /** + * Build limit reached email (100% threshold). + */ + protected function limitReachedEmail( + MailMessage $message, + string $featureName, + string $workspaceName, + string $appName + ): MailMessage { + return $message + ->greeting('Hi,') + ->line("Your **{$workspaceName}** workspace has reached its **{$featureName}** limit.") + ->line("**Usage:** {$this->used} of {$this->limit} (100%)") + ->line('You will not be able to use this feature until:') + ->line('- You upgrade to a higher plan, or') + ->line('- Your usage resets (if applicable), or') + ->line('- You reduce your current usage') + ->action('Upgrade Plan', route('hub.billing')) + ->line('Need assistance? Our support team is here to help.') + ->salutation("Cheers, the {$appName} team"); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'usage_alert', + 'workspace_id' => $this->workspace->id, + 'workspace_name' => $this->workspace->name, + 'feature_code' => $this->feature->code, + 'feature_name' => $this->feature->name, + 'threshold' => $this->threshold, + 'used' => $this->used, + 'limit' => $this->limit, + 'percentage' => round(($this->used / $this->limit) * 100), + ]; + } +} diff --git a/src/Notifications/WaitlistInviteNotification.php b/src/Notifications/WaitlistInviteNotification.php new file mode 100644 index 0000000..4798764 --- /dev/null +++ b/src/Notifications/WaitlistInviteNotification.php @@ -0,0 +1,69 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $registerUrl = route('register', ['invite' => $this->entry->invite_code]); + $name = $this->entry->name ?: 'there'; + + return (new MailMessage) + ->subject('Your Host UK invite is ready') + ->greeting("Hello {$name},") + ->line('Good news. Your spot on the Host UK waitlist has come up and you can now create your account.') + ->line('**Your invite code:** '.$this->entry->invite_code) + ->line('As an early member, you\'ll get **50% off your first 3 months** when you upgrade to a paid plan.') + ->action('Create your account', $registerUrl) + ->line('This invite is linked to your email address and can only be used once.') + ->line('Here\'s what you\'ll get access to:') + ->line('• **BioHost** – Create bio pages with 60+ content blocks') + ->line('• **SocialHost** – Schedule posts across 20+ social platforms') + ->line('• **AnalyticsHost** – Privacy-first website analytics') + ->line('• **TrustHost** – Social proof widgets for your site') + ->line('• **NotifyHost** – Browser push notifications') + ->line('Questions? Just reply to this email.') + ->salutation('Cheers, the Host UK team'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'waitlist_invite', + 'invite_code' => $this->entry->invite_code, + ]; + } +} diff --git a/src/Notifications/WelcomeNotification.php b/src/Notifications/WelcomeNotification.php new file mode 100644 index 0000000..42e0a2e --- /dev/null +++ b/src/Notifications/WelcomeNotification.php @@ -0,0 +1,57 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('Welcome to Host UK') + ->greeting('Hello '.($notifiable->name ?: 'there').',') + ->line('Thanks for creating your Host UK account. You\'re all set to start building your online presence.') + ->line('Here\'s what you can do next:') + ->line('• **BioHost** – Create a bio page with 60+ content blocks') + ->line('• **SocialHost** – Schedule posts across 20+ social platforms') + ->line('• **AnalyticsHost** – Track your website visitors with privacy-first analytics') + ->line('• **TrustHost** – Add social proof widgets to your site') + ->line('• **NotifyHost** – Send browser push notifications') + ->action('Go to Dashboard', route('hub.dashboard')) + ->line('If you have any questions, just reply to this email.') + ->salutation('Cheers, the Host UK team'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'welcome', + ]; + } +} diff --git a/src/Notifications/WorkspaceInvitationNotification.php b/src/Notifications/WorkspaceInvitationNotification.php new file mode 100644 index 0000000..7879274 --- /dev/null +++ b/src/Notifications/WorkspaceInvitationNotification.php @@ -0,0 +1,65 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $acceptUrl = route('workspace.invitation.accept', ['token' => $this->invitation->token]); + $workspaceName = $this->invitation->workspace->name; + $inviterName = $this->invitation->inviter?->name ?? 'A team member'; + $roleName = ucfirst($this->invitation->role); + $expiresAt = $this->invitation->expires_at->format('j F Y'); + + return (new MailMessage) + ->subject("You've been invited to join {$workspaceName}") + ->greeting('Hello,') + ->line("{$inviterName} has invited you to join **{$workspaceName}** as a **{$roleName}**.") + ->action('Accept invitation', $acceptUrl) + ->line("This invitation will expire on {$expiresAt}.") + ->line('If you did not expect this invitation, you can safely ignore this email.'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'workspace_invitation', + 'workspace_id' => $this->invitation->workspace_id, + 'workspace_name' => $this->invitation->workspace->name, + 'role' => $this->invitation->role, + ]; + } +} diff --git a/src/Routes/api.php b/src/Routes/api.php new file mode 100644 index 0000000..fd148cb --- /dev/null +++ b/src/Routes/api.php @@ -0,0 +1,82 @@ +prefix('workspaces')->name('api.workspaces.')->group(function () { + Route::get('/', [WorkspaceController::class, 'index']) + ->name('index'); + Route::get('/current', [WorkspaceController::class, 'current']) + ->name('current'); + Route::post('/', [WorkspaceController::class, 'store']) + ->name('store'); + Route::get('/{workspace}', [WorkspaceController::class, 'show']) + ->name('show'); + Route::put('/{workspace}', [WorkspaceController::class, 'update']) + ->name('update'); + Route::delete('/{workspace}', [WorkspaceController::class, 'destroy']) + ->name('destroy'); + Route::post('/{workspace}/switch', [WorkspaceController::class, 'switch']) + ->name('switch'); +}); + +/* +|-------------------------------------------------------------------------- +| Workspaces API (API Key Auth) +|-------------------------------------------------------------------------- +| +| Read-only workspace access via API key. +| Use Authorization: Bearer hk_xxx header. +| +*/ + +Route::middleware(['api.auth', 'api.scope.enforce'])->prefix('workspaces')->name('api.key.workspaces.')->group(function () { + // Scope enforcement: GET=read (all routes here are read-only) + Route::get('/', [WorkspaceController::class, 'index'])->name('index'); + Route::get('/current', [WorkspaceController::class, 'current'])->name('current'); + Route::get('/{workspace}', [WorkspaceController::class, 'show'])->name('show'); +}); + +/* +|-------------------------------------------------------------------------- +| Entitlement Webhooks API (Auth Required) +|-------------------------------------------------------------------------- +| +| Webhook management for entitlement events. +| Session-based authentication. +| +*/ + +Route::middleware('auth')->prefix('entitlement-webhooks')->name('api.entitlement-webhooks.')->group(function () { + Route::get('/', [EntitlementWebhookController::class, 'index'])->name('index'); + Route::get('/events', [EntitlementWebhookController::class, 'events'])->name('events'); + Route::post('/', [EntitlementWebhookController::class, 'store'])->name('store'); + Route::get('/{webhook}', [EntitlementWebhookController::class, 'show'])->name('show'); + Route::put('/{webhook}', [EntitlementWebhookController::class, 'update'])->name('update'); + Route::delete('/{webhook}', [EntitlementWebhookController::class, 'destroy'])->name('destroy'); + Route::post('/{webhook}/test', [EntitlementWebhookController::class, 'test'])->name('test'); + Route::post('/{webhook}/regenerate-secret', [EntitlementWebhookController::class, 'regenerateSecret'])->name('regenerate-secret'); + Route::post('/{webhook}/reset-circuit-breaker', [EntitlementWebhookController::class, 'resetCircuitBreaker'])->name('reset-circuit-breaker'); + Route::get('/{webhook}/deliveries', [EntitlementWebhookController::class, 'deliveries'])->name('deliveries'); + Route::post('/deliveries/{delivery}/retry', [EntitlementWebhookController::class, 'retryDelivery'])->name('retry-delivery'); +}); diff --git a/src/Routes/web.php b/src/Routes/web.php new file mode 100644 index 0000000..e3bf445 --- /dev/null +++ b/src/Routes/web.php @@ -0,0 +1,59 @@ +name('account.')->group(function () { + Route::get('/delete/{token}', ConfirmDeletion::class) + ->name('delete.confirm'); + + Route::get('/delete/{token}/cancel', CancelDeletion::class) + ->name('delete.cancel'); +}); + +/* +|-------------------------------------------------------------------------- +| Workspace Invitation Routes +|-------------------------------------------------------------------------- +| +| Token-based workspace invitation acceptance. +| Users receive these links via email to join a workspace. +| +*/ + +Route::get('/workspace/invitation/{token}', \Core\Mod\Tenant\Controllers\WorkspaceInvitationController::class) + ->name('workspace.invitation.accept'); + +/* +|-------------------------------------------------------------------------- +| Workspace Public Routes +|-------------------------------------------------------------------------- +| +| Workspace home page, typically accessed via subdomain. +| The workspace slug is resolved from subdomain middleware or route param. +| +*/ + +Route::get('/workspace/{workspace?}', WorkspaceHome::class) + ->name('workspace.home') + ->where('workspace', '[a-z0-9\-]+'); diff --git a/src/Rules/CheckUserPasswordRule.php b/src/Rules/CheckUserPasswordRule.php new file mode 100644 index 0000000..94d5814 --- /dev/null +++ b/src/Rules/CheckUserPasswordRule.php @@ -0,0 +1,45 @@ +user->password)) { + $fail($this->message ?: 'The password is incorrect.'); + } + } +} diff --git a/src/Rules/ResourceStatusRule.php b/src/Rules/ResourceStatusRule.php new file mode 100644 index 0000000..8d338c0 --- /dev/null +++ b/src/Rules/ResourceStatusRule.php @@ -0,0 +1,39 @@ +value, ResourceStatus::ENABLED->value], true)) { + $fail('The :attribute must be either enabled or disabled.'); + } + } +} diff --git a/src/Scopes/WorkspaceScope.php b/src/Scopes/WorkspaceScope.php new file mode 100644 index 0000000..3af629f --- /dev/null +++ b/src/Scopes/WorkspaceScope.php @@ -0,0 +1,174 @@ +hasWorkspaceColumn($model)) { + return; + } + + // Get current workspace (returns Workspace model instance) + $workspace = Workspace::current(); + + if ($workspace) { + $builder->where($model->getTable().'.workspace_id', $workspace->id); + + return; + } + + // No workspace context available + if ($this->shouldEnforceStrictMode($model)) { + throw MissingWorkspaceContextException::forScope( + class_basename($model) + ); + } + + // Non-strict mode: return empty result set (fail safe) + $builder->whereRaw('1 = 0'); + } + + /** + * Check if the model has a workspace_id column. + */ + protected function hasWorkspaceColumn(Model $model): bool + { + $fillable = $model->getFillable(); + $guarded = $model->getGuarded(); + + // Check if workspace_id is in fillable or not in guarded + return in_array('workspace_id', $fillable, true) + || (count($guarded) === 1 && $guarded[0] === '*') + || ! in_array('workspace_id', $guarded, true); + } + + /** + * Determine if strict mode should be enforced for a model. + */ + protected function shouldEnforceStrictMode(Model $model): bool + { + // Check global strict mode setting + if (! self::$strictModeEnabled) { + return false; + } + + // Check if model has opted out of strict mode + if (property_exists($model, 'workspaceScopeStrict') && $model->workspaceScopeStrict === false) { + return false; + } + + // Check if running from console (CLI commands may need to work without context) + if (app()->runningInConsole() && ! app()->runningUnitTests()) { + return false; + } + + return true; + } + + /** + * Extend the query builder with workspace-specific methods. + */ + public function extend(Builder $builder): void + { + // Add method to set workspace context for a query + $builder->macro('forWorkspace', function (Builder $builder, Workspace|int $workspace) { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return $builder->withoutGlobalScope(WorkspaceScope::class) + ->where($builder->getModel()->getTable().'.workspace_id', $workspaceId); + }); + + // Add method to query across all workspaces (use with caution) + $builder->macro('acrossWorkspaces', function (Builder $builder) { + return $builder->withoutGlobalScope(WorkspaceScope::class); + }); + + // Add method to get current workspace for a query + $builder->macro('currentWorkspaceId', function (Builder $builder) { + $workspace = Workspace::current(); + + return $workspace?->id; + }); + } +} diff --git a/src/Services/EntitlementResult.php b/src/Services/EntitlementResult.php new file mode 100644 index 0000000..078ba09 --- /dev/null +++ b/src/Services/EntitlementResult.php @@ -0,0 +1,174 @@ + true]), + ); + } + + /** + * Check if the request is allowed. + */ + public function isAllowed(): bool + { + return $this->allowed; + } + + /** + * Check if the request is denied. + */ + public function isDenied(): bool + { + return ! $this->allowed; + } + + /** + * Get the denial message. + */ + public function getMessage(): ?string + { + return $this->reason; + } + + /** + * Check if this is an unlimited feature. + */ + public function isUnlimited(): bool + { + return $this->metadata['unlimited'] ?? false; + } + + /** + * Get usage percentage (0-100). + */ + public function getUsagePercentage(): ?float + { + if ($this->limit === null || $this->limit === 0) { + return null; + } + + return min(100, ($this->used ?? 0) / $this->limit * 100); + } + + /** + * Check if usage is near the limit (> 80%). + */ + public function isNearLimit(): bool + { + $percentage = $this->getUsagePercentage(); + + return $percentage !== null && $percentage >= 80; + } + + /** + * Check if usage is at the limit. + */ + public function isAtLimit(): bool + { + return $this->remaining === 0; + } + + /** + * Get the limit value. + */ + public function getLimit(): ?int + { + return $this->limit; + } + + /** + * Get the used value. + */ + public function getUsed(): ?int + { + return $this->used; + } + + /** + * Get the remaining value. + */ + public function getRemaining(): ?int + { + return $this->remaining; + } + + /** + * Convert to array for JSON responses. + */ + public function toArray(): array + { + return [ + 'allowed' => $this->allowed, + 'reason' => $this->reason, + 'limit' => $this->limit, + 'used' => $this->used, + 'remaining' => $this->remaining, + 'feature_code' => $this->featureCode, + 'unlimited' => $this->isUnlimited(), + 'usage_percentage' => $this->getUsagePercentage(), + ]; + } +} diff --git a/src/Services/EntitlementService.php b/src/Services/EntitlementService.php new file mode 100644 index 0000000..1d2cf69 --- /dev/null +++ b/src/Services/EntitlementService.php @@ -0,0 +1,821 @@ +getFeature($featureCode); + + if (! $feature) { + return EntitlementResult::denied( + reason: "Feature '{$featureCode}' does not exist.", + featureCode: $featureCode + ); + } + + // Get the pool feature code (parent if hierarchical) + $poolFeatureCode = $feature->getPoolFeatureCode(); + + // Get total limit from all active packages + boosts + $totalLimit = $this->getTotalLimit($workspace, $poolFeatureCode); + + if ($totalLimit === null) { + // Feature not included in any package + return EntitlementResult::denied( + reason: "Your plan does not include {$feature->name}.", + featureCode: $featureCode + ); + } + + // Check for unlimited + if ($totalLimit === -1) { + return EntitlementResult::unlimited($featureCode); + } + + // For boolean features, just check if enabled + if ($feature->isBoolean()) { + return EntitlementResult::allowed(featureCode: $featureCode); + } + + // Get current usage + $currentUsage = $this->getCurrentUsage($workspace, $poolFeatureCode, $feature); + + // Check if quantity would exceed limit + if ($currentUsage + $quantity > $totalLimit) { + return EntitlementResult::denied( + reason: "You've reached your {$feature->name} limit ({$totalLimit}).", + limit: $totalLimit, + used: $currentUsage, + featureCode: $featureCode + ); + } + + return EntitlementResult::allowed( + limit: $totalLimit, + used: $currentUsage, + featureCode: $featureCode + ); + } + + /** + * Check if a namespace can use a feature. + * + * Entitlement cascade: + * 1. Check namespace-level packages first + * 2. Fall back to workspace pool (if namespace has workspace context) + * 3. Fall back to user tier (for user-owned namespaces without workspace) + */ + public function canForNamespace(Namespace_ $namespace, string $featureCode, int $quantity = 1): EntitlementResult + { + $feature = $this->getFeature($featureCode); + + if (! $feature) { + return EntitlementResult::denied( + reason: "Feature '{$featureCode}' does not exist.", + featureCode: $featureCode + ); + } + + // Get the pool feature code (parent if hierarchical) + $poolFeatureCode = $feature->getPoolFeatureCode(); + + // Try namespace-level limit first + $totalLimit = $this->getNamespaceTotalLimit($namespace, $poolFeatureCode); + + // If not found at namespace level, try workspace fallback + if ($totalLimit === null && $namespace->workspace_id) { + $workspace = $namespace->workspace; + if ($workspace) { + $totalLimit = $this->getTotalLimit($workspace, $poolFeatureCode); + } + } + + // If still not found, try user tier fallback for user-owned namespaces + if ($totalLimit === null && $namespace->isOwnedByUser()) { + $user = $namespace->getOwnerUser(); + if ($user) { + // Check if user's tier includes this feature + if ($feature->isBoolean()) { + $hasFeature = $user->hasFeature($featureCode); + if ($hasFeature) { + return EntitlementResult::allowed(featureCode: $featureCode); + } + } + } + } + + if ($totalLimit === null) { + return EntitlementResult::denied( + reason: "Your plan does not include {$feature->name}.", + featureCode: $featureCode + ); + } + + // Check for unlimited + if ($totalLimit === -1) { + return EntitlementResult::unlimited($featureCode); + } + + // For boolean features, just check if enabled + if ($feature->isBoolean()) { + return EntitlementResult::allowed(featureCode: $featureCode); + } + + // Get current usage + $currentUsage = $this->getNamespaceCurrentUsage($namespace, $poolFeatureCode, $feature); + + // Check if quantity would exceed limit + if ($currentUsage + $quantity > $totalLimit) { + return EntitlementResult::denied( + reason: "You've reached your {$feature->name} limit ({$totalLimit}).", + limit: $totalLimit, + used: $currentUsage, + featureCode: $featureCode + ); + } + + return EntitlementResult::allowed( + limit: $totalLimit, + used: $currentUsage, + featureCode: $featureCode + ); + } + + /** + * Record usage of a feature for a namespace. + */ + public function recordNamespaceUsage( + Namespace_ $namespace, + string $featureCode, + int $quantity = 1, + ?User $user = null, + ?array $metadata = null + ): UsageRecord { + $feature = $this->getFeature($featureCode); + $poolFeatureCode = $feature?->getPoolFeatureCode() ?? $featureCode; + + $record = UsageRecord::create([ + 'namespace_id' => $namespace->id, + 'workspace_id' => $namespace->workspace_id, + 'feature_code' => $poolFeatureCode, + 'quantity' => $quantity, + 'user_id' => $user?->id, + 'metadata' => $metadata, + 'recorded_at' => now(), + ]); + + // Invalidate cache + $this->invalidateNamespaceCache($namespace); + + return $record; + } + + /** + * Record usage of a feature. + */ + public function recordUsage( + Workspace $workspace, + string $featureCode, + int $quantity = 1, + ?User $user = null, + ?array $metadata = null + ): UsageRecord { + $feature = $this->getFeature($featureCode); + $poolFeatureCode = $feature?->getPoolFeatureCode() ?? $featureCode; + + $record = UsageRecord::create([ + 'workspace_id' => $workspace->id, + 'feature_code' => $poolFeatureCode, + 'quantity' => $quantity, + 'user_id' => $user?->id, + 'metadata' => $metadata, + 'recorded_at' => now(), + ]); + + // Invalidate cache + $this->invalidateCache($workspace); + + return $record; + } + + /** + * Provision a package for a workspace. + */ + public function provisionPackage( + Workspace $workspace, + string $packageCode, + array $options = [] + ): WorkspacePackage { + $package = Package::where('code', $packageCode)->firstOrFail(); + + // Check if this is a base package and workspace already has one + if ($package->is_base_package) { + $existingBase = $workspace->workspacePackages() + ->whereHas('package', fn ($q) => $q->where('is_base_package', true)) + ->active() + ->first(); + + if ($existingBase) { + // Cancel existing base package + $existingBase->cancel(now()); + + EntitlementLog::logPackageAction( + $workspace, + EntitlementLog::ACTION_PACKAGE_CANCELLED, + $existingBase, + source: $options['source'] ?? EntitlementLog::SOURCE_SYSTEM, + metadata: ['reason' => 'Replaced by new base package'] + ); + } + } + + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $workspace->id, + 'package_id' => $package->id, + 'status' => WorkspacePackage::STATUS_ACTIVE, + 'starts_at' => $options['starts_at'] ?? now(), + 'expires_at' => $options['expires_at'] ?? null, + 'billing_cycle_anchor' => $options['billing_cycle_anchor'] ?? now(), + 'blesta_service_id' => $options['blesta_service_id'] ?? null, + 'metadata' => $options['metadata'] ?? null, + ]); + + EntitlementLog::logPackageAction( + $workspace, + EntitlementLog::ACTION_PACKAGE_PROVISIONED, + $workspacePackage, + source: $options['source'] ?? EntitlementLog::SOURCE_SYSTEM, + newValues: $workspacePackage->toArray() + ); + + $this->invalidateCache($workspace); + + return $workspacePackage; + } + + /** + * Provision a boost for a workspace. + */ + public function provisionBoost( + Workspace $workspace, + string $featureCode, + array $options = [] + ): Boost { + $boost = Boost::create([ + 'workspace_id' => $workspace->id, + 'feature_code' => $featureCode, + 'boost_type' => $options['boost_type'] ?? Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => $options['duration_type'] ?? Boost::DURATION_CYCLE_BOUND, + 'limit_value' => $options['limit_value'] ?? null, + 'consumed_quantity' => 0, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => $options['starts_at'] ?? now(), + 'expires_at' => $options['expires_at'] ?? null, + 'blesta_addon_id' => $options['blesta_addon_id'] ?? null, + 'metadata' => $options['metadata'] ?? null, + ]); + + EntitlementLog::logBoostAction( + $workspace, + EntitlementLog::ACTION_BOOST_PROVISIONED, + $boost, + source: $options['source'] ?? EntitlementLog::SOURCE_SYSTEM, + newValues: $boost->toArray() + ); + + $this->invalidateCache($workspace); + + return $boost; + } + + /** + * Get usage summary for a workspace. + */ + public function getUsageSummary(Workspace $workspace): Collection + { + $features = Feature::active()->orderBy('category')->orderBy('sort_order')->get(); + $summary = collect(); + + foreach ($features as $feature) { + $result = $this->can($workspace, $feature->code); + + $summary->push([ + 'feature' => $feature, + 'code' => $feature->code, + 'name' => $feature->name, + 'category' => $feature->category, + 'type' => $feature->type, + 'allowed' => $result->isAllowed(), + 'limit' => $result->limit, + 'used' => $result->used, + 'remaining' => $result->remaining, + 'unlimited' => $result->isUnlimited(), + 'percentage' => $result->getUsagePercentage(), + 'near_limit' => $result->isNearLimit(), + ]); + } + + return $summary->groupBy('category'); + } + + /** + * Get all active packages for a workspace. + */ + public function getActivePackages(Workspace $workspace): Collection + { + return $workspace->workspacePackages() + ->with('package.features') + ->active() + ->notExpired() + ->get(); + } + + /** + * Get all active boosts for a workspace. + */ + public function getActiveBoosts(Workspace $workspace): Collection + { + return $workspace->boosts() + ->usable() + ->orderBy('expires_at') + ->get(); + } + + /** + * Suspend a workspace's packages (e.g. for non-payment). + */ + public function suspendWorkspace(Workspace $workspace, ?string $source = null): void + { + $packages = $workspace->workspacePackages()->active()->get(); + + foreach ($packages as $workspacePackage) { + $workspacePackage->suspend(); + + EntitlementLog::logPackageAction( + $workspace, + EntitlementLog::ACTION_PACKAGE_SUSPENDED, + $workspacePackage, + source: $source ?? EntitlementLog::SOURCE_SYSTEM + ); + } + + $this->invalidateCache($workspace); + } + + /** + * Reactivate a workspace's packages. + */ + public function reactivateWorkspace(Workspace $workspace, ?string $source = null): void + { + $packages = $workspace->workspacePackages() + ->where('status', WorkspacePackage::STATUS_SUSPENDED) + ->get(); + + foreach ($packages as $workspacePackage) { + $workspacePackage->reactivate(); + + EntitlementLog::logPackageAction( + $workspace, + EntitlementLog::ACTION_PACKAGE_REACTIVATED, + $workspacePackage, + source: $source ?? EntitlementLog::SOURCE_SYSTEM + ); + } + + $this->invalidateCache($workspace); + } + + /** + * Revoke a package from a workspace (e.g. subscription cancelled). + */ + public function revokePackage(Workspace $workspace, string $packageCode, ?string $source = null): void + { + $workspacePackage = $workspace->workspacePackages() + ->whereHas('package', fn ($q) => $q->where('code', $packageCode)) + ->active() + ->first(); + + if (! $workspacePackage) { + return; + } + + $workspacePackage->update([ + 'status' => WorkspacePackage::STATUS_CANCELLED, + 'expires_at' => now(), + ]); + + EntitlementLog::logPackageAction( + $workspace, + EntitlementLog::ACTION_PACKAGE_CANCELLED, + $workspacePackage, + source: $source ?? EntitlementLog::SOURCE_SYSTEM, + metadata: ['reason' => 'Package revoked'] + ); + + $this->invalidateCache($workspace); + } + + /** + * Get the total limit for a feature across all packages + boosts. + * + * Returns null if feature not included, -1 if unlimited. + */ + protected function getTotalLimit(Workspace $workspace, string $featureCode): ?int + { + $cacheKey = "entitlement:{$workspace->id}:limit:{$featureCode}"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($workspace, $featureCode) { + $feature = $this->getFeature($featureCode); + + if (! $feature) { + return null; + } + + $totalLimit = 0; + $hasFeature = false; + + // Sum limits from active packages + $packages = $this->getActivePackages($workspace); + + foreach ($packages as $workspacePackage) { + $packageFeature = $workspacePackage->package->features + ->where('code', $featureCode) + ->first(); + + if ($packageFeature) { + $hasFeature = true; + + // Check if unlimited in this package + if ($packageFeature->type === Feature::TYPE_UNLIMITED) { + return -1; + } + + // Add limit value (null = boolean, no limit to add) + $limitValue = $packageFeature->pivot->limit_value; + if ($limitValue !== null) { + $totalLimit += $limitValue; + } + } + } + + // Add limits from active boosts + $boosts = $workspace->boosts() + ->forFeature($featureCode) + ->usable() + ->get(); + + foreach ($boosts as $boost) { + $hasFeature = true; + + if ($boost->boost_type === Boost::BOOST_TYPE_UNLIMITED) { + return -1; + } + + if ($boost->boost_type === Boost::BOOST_TYPE_ADD_LIMIT) { + $remaining = $boost->getRemainingLimit(); + if ($remaining !== null) { + $totalLimit += $remaining; + } + } + } + + return $hasFeature ? $totalLimit : null; + }); + } + + /** + * Get current usage for a feature. + */ + protected function getCurrentUsage(Workspace $workspace, string $featureCode, Feature $feature): int + { + $cacheKey = "entitlement:{$workspace->id}:usage:{$featureCode}"; + + return Cache::remember($cacheKey, 60, function () use ($workspace, $featureCode, $feature) { + // Determine the time window for usage calculation + if ($feature->resetsMonthly()) { + // Get billing cycle anchor from the primary package + $primaryPackage = $workspace->workspacePackages() + ->whereHas('package', fn ($q) => $q->where('is_base_package', true)) + ->active() + ->first(); + + $cycleStart = $primaryPackage + ? $primaryPackage->getCurrentCycleStart() + : now()->startOfMonth(); + + return UsageRecord::getTotalUsage($workspace->id, $featureCode, $cycleStart); + } + + if ($feature->resetsRolling()) { + $days = $feature->rolling_window_days ?? 30; + + return UsageRecord::getRollingUsage($workspace->id, $featureCode, $days); + } + + // No reset - all time usage + return UsageRecord::getTotalUsage($workspace->id, $featureCode); + }); + } + + /** + * Get a feature by code. + */ + protected function getFeature(string $code): ?Feature + { + return Cache::remember("feature:{$code}", self::CACHE_TTL, function () use ($code) { + return Feature::where('code', $code)->first(); + }); + } + + /** + * Invalidate all entitlement caches for a workspace. + */ + public function invalidateCache(Workspace $workspace): void + { + // We can't easily clear pattern-based cache keys with all drivers, + // so we use a version tag approach + Cache::forget("entitlement:{$workspace->id}:version"); + Cache::increment("entitlement:{$workspace->id}:version"); + + // For now, just clear specific known keys + $features = Feature::pluck('code'); + foreach ($features as $code) { + Cache::forget("entitlement:{$workspace->id}:limit:{$code}"); + Cache::forget("entitlement:{$workspace->id}:usage:{$code}"); + } + } + + /** + * Expire cycle-bound boosts at billing cycle end. + */ + public function expireCycleBoundBoosts(Workspace $workspace): void + { + $boosts = $workspace->boosts() + ->where('duration_type', Boost::DURATION_CYCLE_BOUND) + ->where('status', Boost::STATUS_ACTIVE) + ->get(); + + foreach ($boosts as $boost) { + $boost->expire(); + + EntitlementLog::logBoostAction( + $workspace, + EntitlementLog::ACTION_BOOST_EXPIRED, + $boost, + source: EntitlementLog::SOURCE_SYSTEM, + metadata: ['reason' => 'Billing cycle ended'] + ); + } + + $this->invalidateCache($workspace); + } + + // ───────────────────────────────────────────────────────────────────────── + // Namespace-specific methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get the total limit for a feature from namespace-level packages + boosts. + * + * Returns null if feature not included, -1 if unlimited. + */ + protected function getNamespaceTotalLimit(Namespace_ $namespace, string $featureCode): ?int + { + $cacheKey = "entitlement:ns:{$namespace->id}:limit:{$featureCode}"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($namespace, $featureCode) { + $feature = $this->getFeature($featureCode); + + if (! $feature) { + return null; + } + + $totalLimit = 0; + $hasFeature = false; + + // Sum limits from active namespace packages + $packages = $namespace->namespacePackages() + ->with('package.features') + ->active() + ->notExpired() + ->get(); + + foreach ($packages as $namespacePackage) { + $packageFeature = $namespacePackage->package->features + ->where('code', $featureCode) + ->first(); + + if ($packageFeature) { + $hasFeature = true; + + // Check if unlimited in this package + if ($packageFeature->type === Feature::TYPE_UNLIMITED) { + return -1; + } + + // Add limit value (null = boolean, no limit to add) + $limitValue = $packageFeature->pivot->limit_value; + if ($limitValue !== null) { + $totalLimit += $limitValue; + } + } + } + + // Add limits from active namespace-level boosts + $boosts = $namespace->boosts() + ->forFeature($featureCode) + ->usable() + ->get(); + + foreach ($boosts as $boost) { + $hasFeature = true; + + if ($boost->boost_type === Boost::BOOST_TYPE_UNLIMITED) { + return -1; + } + + if ($boost->boost_type === Boost::BOOST_TYPE_ADD_LIMIT) { + $remaining = $boost->getRemainingLimit(); + if ($remaining !== null) { + $totalLimit += $remaining; + } + } + } + + return $hasFeature ? $totalLimit : null; + }); + } + + /** + * Get current usage for a feature at namespace level. + */ + protected function getNamespaceCurrentUsage(Namespace_ $namespace, string $featureCode, Feature $feature): int + { + $cacheKey = "entitlement:ns:{$namespace->id}:usage:{$featureCode}"; + + return Cache::remember($cacheKey, 60, function () use ($namespace, $featureCode, $feature) { + // Determine the time window for usage calculation + if ($feature->resetsMonthly()) { + // Get billing cycle anchor from the primary package + $primaryPackage = $namespace->namespacePackages() + ->whereHas('package', fn ($q) => $q->where('is_base_package', true)) + ->active() + ->first(); + + $cycleStart = $primaryPackage + ? $primaryPackage->getCurrentCycleStart() + : now()->startOfMonth(); + + return UsageRecord::where('namespace_id', $namespace->id) + ->where('feature_code', $featureCode) + ->where('recorded_at', '>=', $cycleStart) + ->sum('quantity'); + } + + if ($feature->resetsRolling()) { + $days = $feature->rolling_window_days ?? 30; + $since = now()->subDays($days); + + return UsageRecord::where('namespace_id', $namespace->id) + ->where('feature_code', $featureCode) + ->where('recorded_at', '>=', $since) + ->sum('quantity'); + } + + // No reset - all time usage + return UsageRecord::where('namespace_id', $namespace->id) + ->where('feature_code', $featureCode) + ->sum('quantity'); + }); + } + + /** + * Get usage summary for a namespace. + */ + public function getNamespaceUsageSummary(Namespace_ $namespace): Collection + { + $features = Feature::active()->orderBy('category')->orderBy('sort_order')->get(); + $summary = collect(); + + foreach ($features as $feature) { + $result = $this->canForNamespace($namespace, $feature->code); + + $summary->push([ + 'feature' => $feature, + 'code' => $feature->code, + 'name' => $feature->name, + 'category' => $feature->category, + 'type' => $feature->type, + 'allowed' => $result->isAllowed(), + 'limit' => $result->limit, + 'used' => $result->used, + 'remaining' => $result->remaining, + 'unlimited' => $result->isUnlimited(), + 'percentage' => $result->getUsagePercentage(), + 'near_limit' => $result->isNearLimit(), + ]); + } + + return $summary->groupBy('category'); + } + + /** + * Provision a package for a namespace. + */ + public function provisionNamespacePackage( + Namespace_ $namespace, + string $packageCode, + array $options = [] + ): NamespacePackage { + $package = Package::where('code', $packageCode)->firstOrFail(); + + // Check if this is a base package and namespace already has one + if ($package->is_base_package) { + $existingBase = $namespace->namespacePackages() + ->whereHas('package', fn ($q) => $q->where('is_base_package', true)) + ->active() + ->first(); + + if ($existingBase) { + // Cancel existing base package + $existingBase->cancel(now()); + } + } + + $namespacePackage = NamespacePackage::create([ + 'namespace_id' => $namespace->id, + 'package_id' => $package->id, + 'status' => NamespacePackage::STATUS_ACTIVE, + 'starts_at' => $options['starts_at'] ?? now(), + 'expires_at' => $options['expires_at'] ?? null, + 'billing_cycle_anchor' => $options['billing_cycle_anchor'] ?? now(), + 'metadata' => $options['metadata'] ?? null, + ]); + + $this->invalidateNamespaceCache($namespace); + + return $namespacePackage; + } + + /** + * Provision a boost for a namespace. + */ + public function provisionNamespaceBoost( + Namespace_ $namespace, + string $featureCode, + array $options = [] + ): Boost { + $boost = Boost::create([ + 'namespace_id' => $namespace->id, + 'workspace_id' => $namespace->workspace_id, + 'feature_code' => $featureCode, + 'boost_type' => $options['boost_type'] ?? Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => $options['duration_type'] ?? Boost::DURATION_CYCLE_BOUND, + 'limit_value' => $options['limit_value'] ?? null, + 'consumed_quantity' => 0, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => $options['starts_at'] ?? now(), + 'expires_at' => $options['expires_at'] ?? null, + 'metadata' => $options['metadata'] ?? null, + ]); + + $this->invalidateNamespaceCache($namespace); + + return $boost; + } + + /** + * Invalidate all entitlement caches for a namespace. + */ + public function invalidateNamespaceCache(Namespace_ $namespace): void + { + $features = Feature::pluck('code'); + foreach ($features as $code) { + Cache::forget("entitlement:ns:{$namespace->id}:limit:{$code}"); + Cache::forget("entitlement:ns:{$namespace->id}:usage:{$code}"); + } + } +} diff --git a/src/Services/EntitlementWebhookService.php b/src/Services/EntitlementWebhookService.php new file mode 100644 index 0000000..c9b2818 --- /dev/null +++ b/src/Services/EntitlementWebhookService.php @@ -0,0 +1,361 @@ + + */ + public function dispatch(Workspace $workspace, EntitlementWebhookEvent $event, bool $async = true): array + { + $eventName = $event::name(); + $results = []; + + $webhooks = EntitlementWebhook::query() + ->forWorkspace($workspace) + ->active() + ->forEvent($eventName) + ->get(); + + foreach ($webhooks as $webhook) { + if ($async) { + // Dispatch via job for async processing + DispatchEntitlementWebhook::dispatch($webhook->id, $eventName, $event->payload()); + + $results[] = [ + 'webhook_id' => $webhook->id, + 'success' => true, + 'queued' => true, + ]; + } else { + // Synchronous dispatch + try { + $delivery = $webhook->trigger($event); + $results[] = [ + 'webhook_id' => $webhook->id, + 'success' => $delivery->isSucceeded(), + 'delivery_id' => $delivery->id, + ]; + } catch (\Exception $e) { + Log::error('Webhook dispatch failed', [ + 'webhook_id' => $webhook->id, + 'event' => $eventName, + 'error' => $e->getMessage(), + ]); + + $results[] = [ + 'webhook_id' => $webhook->id, + 'success' => false, + 'error' => $e->getMessage(), + ]; + } + } + } + + return $results; + } + + /** + * Register a new webhook for a workspace. + */ + public function register( + Workspace $workspace, + string $name, + string $url, + array $events, + ?string $secret = null, + array $metadata = [] + ): EntitlementWebhook { + // Generate secret if not provided + $secret ??= bin2hex(random_bytes(32)); + + return EntitlementWebhook::create([ + 'workspace_id' => $workspace->id, + 'name' => $name, + 'url' => $url, + 'secret' => $secret, + 'events' => array_intersect($events, EntitlementWebhook::EVENTS), + 'is_active' => true, + 'max_attempts' => 3, + 'metadata' => $metadata, + ]); + } + + /** + * Unregister (delete) a webhook. + */ + public function unregister(EntitlementWebhook $webhook): bool + { + return $webhook->delete(); + } + + /** + * Update webhook configuration. + */ + public function update( + EntitlementWebhook $webhook, + array $attributes + ): EntitlementWebhook { + // Filter events to only allowed values + if (isset($attributes['events'])) { + $attributes['events'] = array_intersect($attributes['events'], EntitlementWebhook::EVENTS); + } + + $webhook->update($attributes); + + return $webhook->refresh(); + } + + /** + * Sign a payload with HMAC-SHA256. + */ + public function sign(array $payload, string $secret): string + { + return hash_hmac('sha256', json_encode($payload), $secret); + } + + /** + * Verify a webhook signature. + */ + public function verifySignature(array $payload, string $signature, string $secret): bool + { + $expected = $this->sign($payload, $secret); + + return hash_equals($expected, $signature); + } + + /** + * Get all available event types with descriptions. + * + * @return array}> + */ + public function getAvailableEvents(): array + { + return [ + 'limit_warning' => [ + 'name' => LimitWarningEvent::nameLocalised(), + 'description' => __('Triggered when usage reaches 80% or 90% of a feature limit'), + 'class' => LimitWarningEvent::class, + ], + 'limit_reached' => [ + 'name' => LimitReachedEvent::nameLocalised(), + 'description' => __('Triggered when usage reaches 100% of a feature limit'), + 'class' => LimitReachedEvent::class, + ], + 'package_changed' => [ + 'name' => PackageChangedEvent::nameLocalised(), + 'description' => __('Triggered when a workspace package is added, changed, or removed'), + 'class' => PackageChangedEvent::class, + ], + 'boost_activated' => [ + 'name' => BoostActivatedEvent::nameLocalised(), + 'description' => __('Triggered when a boost is activated for a workspace'), + 'class' => BoostActivatedEvent::class, + ], + 'boost_expired' => [ + 'name' => BoostExpiredEvent::nameLocalised(), + 'description' => __('Triggered when a boost expires'), + 'class' => BoostExpiredEvent::class, + ], + ]; + } + + /** + * Get event names as a simple array for forms. + * + * @return array + */ + public function getEventOptions(): array + { + $events = $this->getAvailableEvents(); + $options = []; + + foreach ($events as $key => $event) { + $options[$key] = $event['name']; + } + + return $options; + } + + /** + * Test a webhook by sending a test event. + */ + public function testWebhook(EntitlementWebhook $webhook): EntitlementWebhookDelivery + { + $testPayload = [ + 'event' => 'test', + 'data' => [ + 'webhook_id' => $webhook->id, + 'webhook_name' => $webhook->name, + 'message' => 'This is a test webhook delivery from '.$webhook->workspace->name, + 'subscribed_events' => $webhook->events, + ], + 'timestamp' => now()->toIso8601String(), + ]; + + try { + $headers = [ + 'Content-Type' => 'application/json', + 'X-Request-Source' => config('app.name'), + 'User-Agent' => config('app.name').' Entitlement Webhook', + 'X-Test-Webhook' => 'true', + ]; + + if ($webhook->secret) { + $headers['X-Signature'] = $this->sign($testPayload, $webhook->secret); + } + + $response = Http::withHeaders($headers) + ->timeout(10) + ->post($webhook->url, $testPayload); + + $status = in_array($response->status(), [200, 201, 202, 204]) + ? WebhookDeliveryStatus::SUCCESS + : WebhookDeliveryStatus::FAILED; + + return $webhook->deliveries()->create([ + 'uuid' => Str::uuid(), + 'event' => 'test', + 'status' => $status, + 'http_status' => $response->status(), + 'payload' => $testPayload, + 'response' => $response->json() ?: ['body' => $response->body()], + 'created_at' => now(), + ]); + } catch (\Exception $e) { + return $webhook->deliveries()->create([ + 'uuid' => Str::uuid(), + 'event' => 'test', + 'status' => WebhookDeliveryStatus::FAILED, + 'payload' => $testPayload, + 'response' => ['error' => $e->getMessage()], + 'created_at' => now(), + ]); + } + } + + /** + * Retry a failed delivery. + */ + public function retryDelivery(EntitlementWebhookDelivery $delivery): EntitlementWebhookDelivery + { + $webhook = $delivery->webhook; + + if (! $webhook->isActive()) { + throw new \RuntimeException('Cannot retry delivery for inactive webhook'); + } + + $payload = $delivery->payload; + + try { + $headers = [ + 'Content-Type' => 'application/json', + 'X-Request-Source' => config('app.name'), + 'User-Agent' => config('app.name').' Entitlement Webhook', + 'X-Retry-Attempt' => (string) ($delivery->attempts + 1), + ]; + + if ($webhook->secret) { + $headers['X-Signature'] = $this->sign($payload, $webhook->secret); + } + + $response = Http::withHeaders($headers) + ->timeout(10) + ->post($webhook->url, $payload); + + $status = in_array($response->status(), [200, 201, 202, 204]) + ? WebhookDeliveryStatus::SUCCESS + : WebhookDeliveryStatus::FAILED; + + $delivery->update([ + 'attempts' => $delivery->attempts + 1, + 'status' => $status, + 'http_status' => $response->status(), + 'response' => $response->json() ?: ['body' => $response->body()], + 'resent_manually' => true, + ]); + + if ($status === WebhookDeliveryStatus::SUCCESS) { + $webhook->resetFailureCount(); + } else { + $webhook->incrementFailureCount(); + } + + $webhook->updateLastDeliveryStatus($status); + + return $delivery; + } catch (\Exception $e) { + $delivery->update([ + 'attempts' => $delivery->attempts + 1, + 'status' => WebhookDeliveryStatus::FAILED, + 'response' => ['error' => $e->getMessage()], + 'resent_manually' => true, + ]); + + $webhook->incrementFailureCount(); + $webhook->updateLastDeliveryStatus(WebhookDeliveryStatus::FAILED); + + return $delivery; + } + } + + /** + * Re-enable a circuit-broken webhook after fixing the issue. + */ + public function resetCircuitBreaker(EntitlementWebhook $webhook): void + { + $webhook->update([ + 'is_active' => true, + 'failure_count' => 0, + ]); + } + + /** + * Get webhooks for a workspace. + */ + public function getWebhooksForWorkspace(Workspace $workspace): \Illuminate\Database\Eloquent\Collection + { + return EntitlementWebhook::query() + ->forWorkspace($workspace) + ->with(['deliveries' => fn ($q) => $q->latest('created_at')->limit(5)]) + ->latest() + ->get(); + } + + /** + * Get delivery history for a webhook. + */ + public function getDeliveryHistory(EntitlementWebhook $webhook, int $limit = 50): \Illuminate\Database\Eloquent\Collection + { + return $webhook->deliveries() + ->latest('created_at') + ->limit($limit) + ->get(); + } +} diff --git a/src/Services/NamespaceManager.php b/src/Services/NamespaceManager.php new file mode 100644 index 0000000..94444bc --- /dev/null +++ b/src/Services/NamespaceManager.php @@ -0,0 +1,278 @@ +fill([ + 'name' => $data['name'], + 'slug' => $data['slug'] ?? Str::slug($data['name']), + 'description' => $data['description'] ?? null, + 'icon' => $data['icon'] ?? 'folder', + 'color' => $data['color'] ?? 'zinc', + 'owner_type' => User::class, + 'owner_id' => $user->id, + 'workspace_id' => $data['workspace_id'] ?? null, + 'settings' => $data['settings'] ?? null, + 'is_default' => $data['is_default'] ?? false, + 'is_active' => $data['is_active'] ?? true, + 'sort_order' => $data['sort_order'] ?? 0, + ]); + + // If this is marked as default, unset other defaults + if ($namespace->is_default) { + Namespace_::ownedByUser($user) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + $namespace->save(); + + // Invalidate cache + $this->namespaceService->invalidateUserCache($user); + + return $namespace; + } + + /** + * Create a namespace for a workspace. + */ + public function createForWorkspace(Workspace $workspace, array $data): Namespace_ + { + $namespace = new Namespace_; + $namespace->fill([ + 'name' => $data['name'], + 'slug' => $data['slug'] ?? Str::slug($data['name']), + 'description' => $data['description'] ?? null, + 'icon' => $data['icon'] ?? 'folder', + 'color' => $data['color'] ?? 'zinc', + 'owner_type' => Workspace::class, + 'owner_id' => $workspace->id, + 'workspace_id' => $workspace->id, // Billing context is the owner workspace + 'settings' => $data['settings'] ?? null, + 'is_default' => $data['is_default'] ?? false, + 'is_active' => $data['is_active'] ?? true, + 'sort_order' => $data['sort_order'] ?? 0, + ]); + + // If this is marked as default, unset other defaults + if ($namespace->is_default) { + Namespace_::ownedByWorkspace($workspace) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + $namespace->save(); + + // Invalidate cache for all workspace members + foreach ($workspace->users as $member) { + $this->namespaceService->invalidateUserCache($member); + } + + return $namespace; + } + + /** + * Create the default namespace for a user. + * + * This is typically called when a user first signs up. + */ + public function createDefaultForUser(User $user): Namespace_ + { + return $this->createForUser($user, [ + 'name' => 'Personal', + 'slug' => 'personal', + 'description' => 'Your personal workspace', + 'icon' => 'user', + 'color' => 'blue', + 'is_default' => true, + ]); + } + + /** + * Create the default namespace for a workspace. + * + * This is typically called when a workspace is created. + */ + public function createDefaultForWorkspace(Workspace $workspace): Namespace_ + { + return $this->createForWorkspace($workspace, [ + 'name' => $workspace->name, + 'slug' => 'default', + 'description' => "Default namespace for {$workspace->name}", + 'icon' => $workspace->icon ?? 'building', + 'color' => $workspace->color ?? 'zinc', + 'is_default' => true, + ]); + } + + /** + * Update a namespace. + */ + public function update(Namespace_ $namespace, array $data): Namespace_ + { + $wasDefault = $namespace->is_default; + + $namespace->fill(array_filter([ + 'name' => $data['name'] ?? null, + 'slug' => $data['slug'] ?? null, + 'description' => $data['description'] ?? null, + 'icon' => $data['icon'] ?? null, + 'color' => $data['color'] ?? null, + 'workspace_id' => array_key_exists('workspace_id', $data) ? $data['workspace_id'] : $namespace->workspace_id, + 'settings' => $data['settings'] ?? null, + 'is_default' => $data['is_default'] ?? null, + 'is_active' => $data['is_active'] ?? null, + 'sort_order' => $data['sort_order'] ?? null, + ], fn ($v) => $v !== null)); + + // If becoming default, unset other defaults for same owner + if (! $wasDefault && $namespace->is_default) { + Namespace_::where('owner_type', $namespace->owner_type) + ->where('owner_id', $namespace->owner_id) + ->where('id', '!=', $namespace->id) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + $namespace->save(); + + // Invalidate cache + $this->namespaceService->invalidateCache($namespace->uuid); + $this->invalidateCacheForOwner($namespace); + + return $namespace; + } + + /** + * Delete (soft delete) a namespace. + */ + public function delete(Namespace_ $namespace): bool + { + // Invalidate cache first + $this->namespaceService->invalidateCache($namespace->uuid); + $this->invalidateCacheForOwner($namespace); + + // If this was the default, make another one default + if ($namespace->is_default) { + $newDefault = Namespace_::where('owner_type', $namespace->owner_type) + ->where('owner_id', $namespace->owner_id) + ->where('id', '!=', $namespace->id) + ->active() + ->ordered() + ->first(); + + if ($newDefault) { + $newDefault->update(['is_default' => true]); + } + } + + return $namespace->delete(); + } + + /** + * Restore a soft-deleted namespace. + */ + public function restore(Namespace_ $namespace): bool + { + $result = $namespace->restore(); + + // Invalidate cache + $this->namespaceService->invalidateCache($namespace->uuid); + $this->invalidateCacheForOwner($namespace); + + return $result; + } + + /** + * Set a namespace as the default for its owner. + */ + public function setAsDefault(Namespace_ $namespace): Namespace_ + { + // Unset other defaults + Namespace_::where('owner_type', $namespace->owner_type) + ->where('owner_id', $namespace->owner_id) + ->where('id', '!=', $namespace->id) + ->where('is_default', true) + ->update(['is_default' => false]); + + // Set this as default + $namespace->update(['is_default' => true]); + + // Invalidate cache + $this->invalidateCacheForOwner($namespace); + + return $namespace; + } + + /** + * Transfer a namespace to a new owner. + */ + public function transfer(Namespace_ $namespace, User|Workspace $newOwner): Namespace_ + { + $oldOwnerType = $namespace->owner_type; + $oldOwnerId = $namespace->owner_id; + + // Update ownership + $namespace->update([ + 'owner_type' => $newOwner::class, + 'owner_id' => $newOwner->id, + 'is_default' => false, // Can't be default in new context automatically + ]); + + // Invalidate cache + $this->namespaceService->invalidateCache($namespace->uuid); + + // Invalidate for old owner + if ($oldOwnerType === User::class) { + $this->namespaceService->invalidateUserCache(User::find($oldOwnerId)); + } else { + $workspace = Workspace::find($oldOwnerId); + foreach ($workspace->users as $member) { + $this->namespaceService->invalidateUserCache($member); + } + } + + // Invalidate for new owner + $this->invalidateCacheForOwner($namespace); + + return $namespace; + } + + /** + * Invalidate cache for the owner of a namespace. + */ + protected function invalidateCacheForOwner(Namespace_ $namespace): void + { + if ($namespace->isOwnedByUser()) { + $this->namespaceService->invalidateUserCache($namespace->owner); + } else { + foreach ($namespace->owner->users as $member) { + $this->namespaceService->invalidateUserCache($member); + } + } + } +} diff --git a/src/Services/NamespaceService.php b/src/Services/NamespaceService.php new file mode 100644 index 0000000..91418d2 --- /dev/null +++ b/src/Services/NamespaceService.php @@ -0,0 +1,288 @@ +attributes->has('current_namespace')) { + return request()->attributes->get('current_namespace'); + } + + // Try from session + $uuid = session('current_namespace_uuid'); + if ($uuid) { + $namespace = $this->findByUuid($uuid); + if ($namespace && $this->canAccess($namespace)) { + return $namespace; + } + } + + // Fall back to user's default + return $this->defaultForCurrentUser(); + } + + /** + * Get the current namespace UUID from session. + */ + public function currentUuid(): ?string + { + return session('current_namespace_uuid'); + } + + /** + * Set the current namespace in session. + */ + public function setCurrent(Namespace_|string $namespace): void + { + $uuid = $namespace instanceof Namespace_ ? $namespace->uuid : $namespace; + + session(['current_namespace_uuid' => $uuid]); + } + + /** + * Clear the current namespace from session. + */ + public function clearCurrent(): void + { + session()->forget('current_namespace_uuid'); + } + + /** + * Find a namespace by UUID. + */ + public function findByUuid(string $uuid): ?Namespace_ + { + return Cache::remember( + "namespace:uuid:{$uuid}", + self::CACHE_TTL, + fn () => Namespace_::where('uuid', $uuid)->first() + ); + } + + /** + * Find a namespace by slug within an owner context. + */ + public function findBySlug(string $slug, User|Workspace $owner): ?Namespace_ + { + return Namespace_::where('owner_type', $owner::class) + ->where('owner_id', $owner->id) + ->where('slug', $slug) + ->first(); + } + + /** + * Get the default namespace for the current authenticated user. + */ + public function defaultForCurrentUser(): ?Namespace_ + { + $user = auth()->user(); + + if (! $user instanceof User) { + return null; + } + + return $this->defaultForUser($user); + } + + /** + * Get the default namespace for a user. + * + * Priority: + * 1. User's default namespace (is_default = true) + * 2. First active user-owned namespace + * 3. First namespace from user's default workspace + */ + public function defaultForUser(User $user): ?Namespace_ + { + // Try user's explicit default + $default = Namespace_::ownedByUser($user) + ->where('is_default', true) + ->active() + ->first(); + + if ($default) { + return $default; + } + + // Try first user-owned namespace + $userOwned = Namespace_::ownedByUser($user) + ->active() + ->ordered() + ->first(); + + if ($userOwned) { + return $userOwned; + } + + // Try namespace from user's default workspace + $workspace = $user->defaultHostWorkspace(); + if ($workspace) { + return Namespace_::ownedByWorkspace($workspace) + ->active() + ->ordered() + ->first(); + } + + return null; + } + + /** + * Get all namespaces accessible by the current user. + */ + public function accessibleByCurrentUser(): Collection + { + $user = auth()->user(); + + if (! $user instanceof User) { + return collect(); + } + + return $this->accessibleByUser($user); + } + + /** + * Get all namespaces accessible by a user. + */ + public function accessibleByUser(User $user): Collection + { + return Cache::remember( + "user:{$user->id}:accessible_namespaces", + self::CACHE_TTL, + fn () => Namespace_::accessibleBy($user) + ->active() + ->ordered() + ->get() + ); + } + + /** + * Get all namespaces owned by a user. + */ + public function ownedByUser(User $user): Collection + { + return Namespace_::ownedByUser($user) + ->active() + ->ordered() + ->get(); + } + + /** + * Get all namespaces owned by a workspace. + */ + public function ownedByWorkspace(Workspace $workspace): Collection + { + return Namespace_::ownedByWorkspace($workspace) + ->active() + ->ordered() + ->get(); + } + + /** + * Check if the current user can access a namespace. + */ + public function canAccess(Namespace_ $namespace): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return $namespace->isAccessibleBy($user); + } + + /** + * Group namespaces by owner type for UI display. + * + * Returns: + * [ + * 'personal' => Collection of user-owned namespaces, + * 'workspaces' => [ + * ['workspace' => Workspace, 'namespaces' => Collection], + * ... + * ] + * ] + */ + public function groupedForCurrentUser(): array + { + $user = auth()->user(); + + if (! $user instanceof User) { + return ['personal' => collect(), 'workspaces' => []]; + } + + return $this->groupedForUser($user); + } + + /** + * Group namespaces by owner type for a user. + */ + public function groupedForUser(User $user): array + { + $personal = Namespace_::ownedByUser($user) + ->active() + ->ordered() + ->get(); + + $workspaces = []; + foreach ($user->workspaces()->active()->get() as $workspace) { + $namespaces = Namespace_::ownedByWorkspace($workspace) + ->active() + ->ordered() + ->get(); + + if ($namespaces->isNotEmpty()) { + $workspaces[] = [ + 'workspace' => $workspace, + 'namespaces' => $namespaces, + ]; + } + } + + return [ + 'personal' => $personal, + 'workspaces' => $workspaces, + ]; + } + + /** + * Invalidate namespace cache for a user. + */ + public function invalidateUserCache(User $user): void + { + Cache::forget("user:{$user->id}:accessible_namespaces"); + } + + /** + * Invalidate namespace cache by UUID. + */ + public function invalidateCache(string $uuid): void + { + Cache::forget("namespace:uuid:{$uuid}"); + } +} diff --git a/src/Services/TotpService.php b/src/Services/TotpService.php new file mode 100644 index 0000000..54de492 --- /dev/null +++ b/src/Services/TotpService.php @@ -0,0 +1,194 @@ +base32Encode($secret); + } + + /** + * Generate QR code URL for authenticator app setup. + * + * @param string $name Application/account name + * @param string $email User email + * @param string $secret TOTP secret key + */ + public function qrCodeUrl(string $name, string $email, string $secret): string + { + $encodedName = rawurlencode($name); + $encodedEmail = rawurlencode($email); + + return "otpauth://totp/{$encodedName}:{$encodedEmail}?secret={$secret}&issuer={$encodedName}&algorithm=SHA1&digits=6&period=30"; + } + + /** + * Generate a QR code SVG for the given URL. + */ + public function qrCodeSvg(string $url): string + { + $options = new QROptions([ + 'outputType' => QRCode::OUTPUT_MARKUP_SVG, + 'eccLevel' => QRCode::ECC_M, + 'imageBase64' => false, + 'addQuietzone' => true, + 'quietzoneSize' => 2, + 'drawLightModules' => false, + 'svgViewBoxSize' => 200, + ]); + + return (new QRCode($options))->render($url); + } + + /** + * Verify a TOTP code against the secret. + * + * @param string $secret TOTP secret key (base32 encoded) + * @param string $code User-provided 6-digit code + */ + public function verify(string $secret, string $code): bool + { + // Remove any spaces or dashes from the code + $code = preg_replace('/[^0-9]/', '', $code); + + if (strlen($code) !== self::CODE_LENGTH) { + return false; + } + + $secretBytes = $this->base32Decode($secret); + $timestamp = time(); + + // Check current time and adjacent windows for clock drift + for ($i = -self::WINDOW; $i <= self::WINDOW; $i++) { + $calculatedCode = $this->generateCode($secretBytes, $timestamp + ($i * self::TIME_STEP)); + + if (hash_equals($calculatedCode, $code)) { + return true; + } + } + + return false; + } + + /** + * Generate a TOTP code for a given timestamp. + */ + protected function generateCode(string $secretBytes, int $timestamp): string + { + $counter = (int) floor($timestamp / self::TIME_STEP); + + // Pack counter as 64-bit big-endian + $counterBytes = pack('N*', 0, $counter); + + // Generate HMAC + $hash = hash_hmac(self::ALGORITHM, $counterBytes, $secretBytes, true); + + // Dynamic truncation + $offset = ord($hash[strlen($hash) - 1]) & 0x0F; + $binary = + ((ord($hash[$offset]) & 0x7F) << 24) | + ((ord($hash[$offset + 1]) & 0xFF) << 16) | + ((ord($hash[$offset + 2]) & 0xFF) << 8) | + (ord($hash[$offset + 3]) & 0xFF); + + $otp = $binary % (10 ** self::CODE_LENGTH); + + return str_pad((string) $otp, self::CODE_LENGTH, '0', STR_PAD_LEFT); + } + + /** + * Encode bytes as base32. + */ + protected function base32Encode(string $data): string + { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $binary = ''; + + foreach (str_split($data) as $char) { + $binary .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT); + } + + $encoded = ''; + $chunks = str_split($binary, 5); + + foreach ($chunks as $chunk) { + if (strlen($chunk) < 5) { + $chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT); + } + $encoded .= $alphabet[bindec($chunk)]; + } + + return $encoded; + } + + /** + * Decode base32 to bytes. + */ + protected function base32Decode(string $data): string + { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $data = strtoupper($data); + $data = rtrim($data, '='); + + $binary = ''; + foreach (str_split($data) as $char) { + $index = strpos($alphabet, $char); + if ($index === false) { + continue; + } + $binary .= str_pad(decbin($index), 5, '0', STR_PAD_LEFT); + } + + $decoded = ''; + $chunks = str_split($binary, 8); + + foreach ($chunks as $chunk) { + if (strlen($chunk) === 8) { + $decoded .= chr(bindec($chunk)); + } + } + + return $decoded; + } +} diff --git a/src/Services/UsageAlertService.php b/src/Services/UsageAlertService.php new file mode 100644 index 0000000..af0dd68 --- /dev/null +++ b/src/Services/UsageAlertService.php @@ -0,0 +1,356 @@ + 0, + 'alerts_sent' => 0, + 'alerts_resolved' => 0, + ]; + + // Get all active workspaces with packages + $workspaces = Workspace::query() + ->active() + ->whereHas('workspacePackages', fn ($q) => $q->active()) + ->get(); + + foreach ($workspaces as $workspace) { + $result = $this->checkWorkspace($workspace); + $stats['checked']++; + $stats['alerts_sent'] += $result['alerts_sent']; + $stats['alerts_resolved'] += $result['alerts_resolved']; + } + + return $stats; + } + + /** + * Check a single workspace for usage alerts. + * + * @return array{alerts_sent: int, alerts_resolved: int, details: array} + */ + public function checkWorkspace(Workspace $workspace): array + { + $alertsSent = 0; + $alertsResolved = 0; + $details = []; + + // Get all features with limits (not boolean, not unlimited) + $features = Feature::active() + ->where('type', Feature::TYPE_LIMIT) + ->get(); + + foreach ($features as $feature) { + $result = $this->checkFeatureUsage($workspace, $feature); + + if ($result['alert_sent']) { + $alertsSent++; + } + + if ($result['resolved']) { + $alertsResolved++; + } + + if ($result['alert_sent'] || $result['resolved']) { + $details[] = $result; + } + } + + return [ + 'alerts_sent' => $alertsSent, + 'alerts_resolved' => $alertsResolved, + 'details' => $details, + ]; + } + + /** + * Check usage for a specific feature and send alert if needed. + * + * @return array{feature: string, percentage: float|null, threshold: int|null, alert_sent: bool, resolved: bool} + */ + public function checkFeatureUsage(Workspace $workspace, Feature $feature): array + { + $result = [ + 'feature' => $feature->code, + 'percentage' => null, + 'threshold' => null, + 'alert_sent' => false, + 'resolved' => false, + ]; + + // Get entitlement check result + $entitlement = $this->entitlementService->can($workspace, $feature->code); + + // Skip if unlimited or no limit + if ($entitlement->isUnlimited() || $entitlement->limit === null || $entitlement->limit === 0) { + // Check if there are any unresolved alerts to clear + $resolved = UsageAlertHistory::resolveAllForFeature($workspace->id, $feature->code); + $result['resolved'] = $resolved > 0; + + return $result; + } + + $percentage = $entitlement->getUsagePercentage(); + $result['percentage'] = $percentage; + + // Determine the applicable threshold + $applicableThreshold = $this->getApplicableThreshold($percentage); + + // If usage dropped below all thresholds, resolve any active alerts + if ($applicableThreshold === null) { + $resolved = UsageAlertHistory::resolveAllForFeature($workspace->id, $feature->code); + $result['resolved'] = $resolved > 0; + + return $result; + } + + $result['threshold'] = $applicableThreshold; + + // Check if we've already sent an alert for this threshold + if (UsageAlertHistory::hasActiveAlert($workspace->id, $feature->code, $applicableThreshold)) { + return $result; + } + + // Send the alert + $this->sendAlert($workspace, $feature, $applicableThreshold, $entitlement->used, $entitlement->limit); + $result['alert_sent'] = true; + + return $result; + } + + /** + * Determine which threshold applies based on usage percentage. + */ + protected function getApplicableThreshold(?float $percentage): ?int + { + if ($percentage === null) { + return null; + } + + // Return the highest applicable threshold + if ($percentage >= UsageAlertHistory::THRESHOLD_LIMIT) { + return UsageAlertHistory::THRESHOLD_LIMIT; + } + + if ($percentage >= UsageAlertHistory::THRESHOLD_CRITICAL) { + return UsageAlertHistory::THRESHOLD_CRITICAL; + } + + if ($percentage >= UsageAlertHistory::THRESHOLD_WARNING) { + return UsageAlertHistory::THRESHOLD_WARNING; + } + + return null; + } + + /** + * Send a usage alert notification. + */ + protected function sendAlert( + Workspace $workspace, + Feature $feature, + int $threshold, + int $used, + int $limit + ): void { + // Get workspace owner to notify + $owner = $workspace->owner(); + + if (! $owner) { + Log::warning('Cannot send usage alert: workspace has no owner', [ + 'workspace_id' => $workspace->id, + 'feature_code' => $feature->code, + 'threshold' => $threshold, + ]); + + return; + } + + // Record the alert + UsageAlertHistory::record( + workspaceId: $workspace->id, + featureCode: $feature->code, + threshold: $threshold, + metadata: [ + 'used' => $used, + 'limit' => $limit, + 'percentage' => round(($used / $limit) * 100), + 'notified_user_id' => $owner->id, + ] + ); + + // Send notification + $owner->notify(new UsageAlertNotification( + workspace: $workspace, + feature: $feature, + threshold: $threshold, + used: $used, + limit: $limit + )); + + Log::info('Usage alert sent', [ + 'workspace_id' => $workspace->id, + 'workspace_name' => $workspace->name, + 'feature_code' => $feature->code, + 'threshold' => $threshold, + 'used' => $used, + 'limit' => $limit, + 'user_id' => $owner->id, + 'user_email' => $owner->email, + ]); + + // Dispatch webhook event + $this->dispatchWebhook($workspace, $feature, $threshold, $used, $limit); + } + + /** + * Dispatch webhook event for usage alert. + */ + protected function dispatchWebhook( + Workspace $workspace, + Feature $feature, + int $threshold, + int $used, + int $limit + ): void { + // Lazy load webhook service if not injected + $webhookService = $this->webhookService ?? app(EntitlementWebhookService::class); + + // Create appropriate event based on threshold + if ($threshold === UsageAlertHistory::THRESHOLD_LIMIT) { + $event = new LimitReachedEvent($workspace, $feature, $used, $limit); + } else { + $event = new LimitWarningEvent($workspace, $feature, $used, $limit, $threshold); + } + + // Dispatch to all matching webhooks (async) + try { + $webhookService->dispatch($workspace, $event); + } catch (\Exception $e) { + Log::error('Failed to dispatch usage alert webhook', [ + 'workspace_id' => $workspace->id, + 'feature_code' => $feature->code, + 'threshold' => $threshold, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Get current alert status for a workspace. + * + * Returns all features that have active alerts. + */ + public function getActiveAlertsForWorkspace(Workspace $workspace): Collection + { + return UsageAlertHistory::query() + ->forWorkspace($workspace->id) + ->unresolved() + ->with('workspace') + ->orderBy('threshold', 'desc') + ->orderBy('notified_at', 'desc') + ->get(); + } + + /** + * Get usage status for all features in a workspace. + * + * Returns features approaching limits with their alert status. + */ + public function getUsageStatus(Workspace $workspace): Collection + { + $features = Feature::active() + ->where('type', Feature::TYPE_LIMIT) + ->get(); + + return $features->map(function (Feature $feature) use ($workspace) { + $entitlement = $this->entitlementService->can($workspace, $feature->code); + $percentage = $entitlement->getUsagePercentage(); + $activeAlert = UsageAlertHistory::getActiveAlert($workspace->id, $feature->code); + + return [ + 'feature' => $feature, + 'code' => $feature->code, + 'name' => $feature->name, + 'used' => $entitlement->used, + 'limit' => $entitlement->limit, + 'percentage' => $percentage, + 'unlimited' => $entitlement->isUnlimited(), + 'near_limit' => $entitlement->isNearLimit(), + 'at_limit' => $entitlement->isAtLimit(), + 'active_alert' => $activeAlert, + 'alert_threshold' => $activeAlert?->threshold, + ]; + })->filter(fn ($item) => $item['limit'] !== null && ! $item['unlimited']); + } + + /** + * Manually resolve an alert (e.g., after user upgrades). + */ + public function resolveAlert(int $alertId): bool + { + $alert = UsageAlertHistory::find($alertId); + + if (! $alert || $alert->isResolved()) { + return false; + } + + $alert->resolve(); + + Log::info('Usage alert manually resolved', [ + 'alert_id' => $alertId, + 'workspace_id' => $alert->workspace_id, + 'feature_code' => $alert->feature_code, + ]); + + return true; + } + + /** + * Get alert history for a workspace. + */ + public function getAlertHistory(Workspace $workspace, int $days = 30): Collection + { + return UsageAlertHistory::query() + ->forWorkspace($workspace->id) + ->where('notified_at', '>=', now()->subDays($days)) + ->orderBy('notified_at', 'desc') + ->get(); + } +} diff --git a/src/Services/UserStatsService.php b/src/Services/UserStatsService.php new file mode 100644 index 0000000..103caea --- /dev/null +++ b/src/Services/UserStatsService.php @@ -0,0 +1,284 @@ +getTier(); + + $stats = [ + 'quotas' => $this->computeQuotas($user, $tier), + 'services' => $this->computeServiceStats($user), + 'activity' => $this->getRecentActivity($user), + ]; + + // Save to user record + $user->cached_stats = $stats; + $user->stats_computed_at = now(); + $user->save(); + + return $stats; + } + + /** + * Get cached stats or compute fresh if stale (> 5 minutes). + */ + public function getStats(User $user): array + { + // Return cached if fresh (computed within last 5 minutes) + if ($user->stats_computed_at && $user->stats_computed_at->gt(now()->subMinutes(5))) { + return $user->cached_stats ?? $this->getDefaultStats($user); + } + + // For page loads, return cached data immediately and queue refresh + if ($user->cached_stats) { + // Queue background refresh + dispatch(new \Core\Mod\Tenant\Jobs\ComputeUserStats($user->id))->onQueue('stats'); + + return $user->cached_stats; + } + + // No cached data - compute synchronously (first time only) + return $this->computeStats($user); + } + + /** + * Get default stats structure for a user tier. + */ + public function getDefaultStats(User $user): array + { + $tier = $user->getTier(); + + return [ + 'quotas' => $this->getTierLimits($tier), + 'services' => $this->getDefaultServiceStats(), + 'activity' => [], + ]; + } + + /** + * Compute actual quota usage for user. + */ + protected function computeQuotas(User $user, UserTier $tier): array + { + $limits = $this->getTierLimits($tier); + + // Compute actual usage + // Host Hub workspaces the user has access to (via pivot table) + $workspaceCount = $user->hostWorkspaces()->count(); + $limits['workspaces']['used'] = $workspaceCount; + + // Social accounts across all workspaces + // TODO: Implement when social accounts are linked + // $socialAccountCount = ... + + // Scheduled posts + // TODO: Implement when scheduled posts are linked + // $scheduledPostCount = ... + + // Storage usage + // TODO: Implement when media storage tracking is added + // $storageUsed = ... + + return $limits; + } + + /** + * Get tier limits configuration. + */ + protected function getTierLimits(UserTier $tier): array + { + return match ($tier) { + UserTier::HADES => [ + 'workspaces' => ['used' => 0, 'limit' => null, 'label' => 'Workspaces'], + 'social_accounts' => ['used' => 0, 'limit' => null, 'label' => 'Social Accounts'], + 'scheduled_posts' => ['used' => 0, 'limit' => null, 'label' => 'Scheduled Posts'], + 'storage' => ['used' => 0, 'limit' => null, 'label' => 'Storage (GB)'], + ], + UserTier::APOLLO => [ + 'workspaces' => ['used' => 0, 'limit' => 5, 'label' => 'Workspaces'], + 'social_accounts' => ['used' => 0, 'limit' => 25, 'label' => 'Social Accounts'], + 'scheduled_posts' => ['used' => 0, 'limit' => 500, 'label' => 'Scheduled Posts'], + 'storage' => ['used' => 0, 'limit' => 10, 'label' => 'Storage (GB)'], + ], + default => [ + 'workspaces' => ['used' => 0, 'limit' => 1, 'label' => 'Workspaces'], + 'social_accounts' => ['used' => 0, 'limit' => 5, 'label' => 'Social Accounts'], + 'scheduled_posts' => ['used' => 0, 'limit' => 50, 'label' => 'Scheduled Posts'], + 'storage' => ['used' => 0, 'limit' => 1, 'label' => 'Storage (GB)'], + ], + }; + } + + /** + * Compute service stats for user. + */ + protected function computeServiceStats(User $user): array + { + $services = [ + [ + 'name' => 'SocialHost', + 'icon' => 'fa-share-nodes', + 'color' => 'bg-blue-500', + 'status' => 'inactive', + 'stat' => 'Not configured', + ], + [ + 'name' => 'BioHost', + 'icon' => 'fa-id-card', + 'color' => 'bg-violet-500', + 'status' => 'inactive', + 'stat' => 'Not configured', + ], + [ + 'name' => 'AnalyticsHost', + 'icon' => 'fa-chart-line', + 'color' => 'bg-green-500', + 'status' => 'inactive', + 'stat' => 'Not configured', + ], + [ + 'name' => 'TrustHost', + 'icon' => 'fa-shield-check', + 'color' => 'bg-amber-500', + 'status' => 'inactive', + 'stat' => 'Not configured', + ], + ]; + + // Check for active Host Hub workspaces (via pivot table) + $workspaceCount = $user->hostWorkspaces()->count(); + + if ($workspaceCount > 0) { + // SocialHost - check for social accounts + // TODO: Check social accounts when integration is complete + $services[0]['status'] = 'active'; + $services[0]['stat'] = $workspaceCount.' workspace(s)'; + + // BioHost - check for bio pages + // TODO: Check for bio pages when implemented + } + + return $services; + } + + /** + * Get default service stats. + */ + protected function getDefaultServiceStats(): array + { + return [ + [ + 'name' => 'SocialHost', + 'icon' => 'fa-share-nodes', + 'color' => 'bg-blue-500', + 'status' => 'inactive', + 'stat' => 'Not configured', + ], + [ + 'name' => 'BioHost', + 'icon' => 'fa-id-card', + 'color' => 'bg-violet-500', + 'status' => 'inactive', + 'stat' => 'Not configured', + ], + [ + 'name' => 'AnalyticsHost', + 'icon' => 'fa-chart-line', + 'color' => 'bg-green-500', + 'status' => 'inactive', + 'stat' => 'Not configured', + ], + [ + 'name' => 'TrustHost', + 'icon' => 'fa-shield-check', + 'color' => 'bg-amber-500', + 'status' => 'inactive', + 'stat' => 'Not configured', + ], + ]; + } + + /** + * Get recent activity for user. + */ + protected function getRecentActivity(User $user): array + { + // TODO: Implement actual activity logging + // For now return empty - activities will be added when actions are performed + return []; + } + + /** + * Get cached timezone list. + */ + public static function getTimezoneList(): array + { + return Cache::remember('timezone_list', 86400, function () { + $groups = []; + $timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL); + + foreach ($timezones as $tz) { + $parts = explode('/', $tz, 2); + $group = $parts[0] ?? 'Other'; + $label = $parts[1] ?? $tz; + + if (! isset($groups[$group])) { + $groups[$group] = []; + } + + $groups[$group][$tz] = str_replace('_', ' ', $label); + } + + ksort($groups); + foreach ($groups as &$items) { + asort($items); + } + + return $groups; + }); + } + + /** + * Get cached locale list. + */ + public static function getLocaleList(): array + { + return Cache::remember('locale_list', 86400, function () { + $locales = [ + 'en-GB' => 'English (UK)', + 'en-US' => 'English (US)', + 'es' => 'Español', + 'fr' => 'Français', + 'de' => 'Deutsch', + 'it' => 'Italiano', + 'pt' => 'Português', + 'nl' => 'Nederlands', + 'pl' => 'Polski', + 'ru' => 'Русский', + 'ja' => '日本語', + 'zh' => '中文', + 'ko' => '한국어', + 'ar' => 'العربية', + ]; + + $result = []; + foreach ($locales as $code => $name) { + $result[] = ['long' => $code, 'name' => $name]; + } + + return $result; + }); + } +} diff --git a/src/Services/WorkspaceCacheManager.php b/src/Services/WorkspaceCacheManager.php new file mode 100644 index 0000000..ef046f8 --- /dev/null +++ b/src/Services/WorkspaceCacheManager.php @@ -0,0 +1,458 @@ +remember($workspace, 'key', 300, fn() => expensive_query()); + * + * // Clear all cache for a workspace + * $manager->flush($workspace); + * + * // Get cache statistics (useful for debugging) + * $stats = $manager->stats($workspace); + */ +class WorkspaceCacheManager +{ + /** + * Track all cache keys used (for non-tagged stores). + * This allows us to clear cache for a workspace even without tags. + */ + protected static array $keyRegistry = []; + + /** + * Configuration cache. + */ + protected ?array $config = null; + + /** + * Get the configuration for workspace caching. + */ + public function config(?string $key = null, mixed $default = null): mixed + { + if ($this->config === null) { + $this->config = config('core.workspace_cache', [ + 'enabled' => true, + 'ttl' => 300, + 'prefix' => 'workspace_cache', + 'use_tags' => true, + ]); + } + + if ($key === null) { + return $this->config; + } + + return $this->config[$key] ?? $default; + } + + /** + * Check if workspace caching is enabled. + */ + public function isEnabled(): bool + { + return (bool) $this->config('enabled', true); + } + + /** + * Get the cache prefix. + */ + public function prefix(): string + { + return $this->config('prefix', 'workspace_cache'); + } + + /** + * Get the default TTL. + */ + public function defaultTtl(): int + { + return (int) $this->config('ttl', 300); + } + + /** + * Check if the current cache store supports tags. + */ + public function supportsTags(): bool + { + if (! $this->config('use_tags', true)) { + return false; + } + + try { + return Cache::getStore() instanceof TaggableStore; + } catch (\Throwable) { + return false; + } + } + + /** + * Get the workspace tag name. + */ + public function workspaceTag(Workspace|int $workspace): string + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return $this->prefix().":workspace:{$workspaceId}"; + } + + /** + * Get the model tag name. + */ + public function modelTag(string $modelClass): string + { + $modelName = class_basename($modelClass); + + return $this->prefix().":model:{$modelName}"; + } + + /** + * Generate a cache key for a workspace-scoped value. + */ + public function key(Workspace|int $workspace, string $key): string + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return "{$this->prefix()}.{$workspaceId}.{$key}"; + } + + /** + * Remember a value in the cache for a workspace. + * + * @template T + * + * @param Workspace|int $workspace The workspace context + * @param string $key The cache key (will be prefixed automatically) + * @param int|null $ttl TTL in seconds (null = use default) + * @param Closure(): T $callback The callback to generate the value + * @return T + */ + public function remember(Workspace|int $workspace, string $key, ?int $ttl, Closure $callback): mixed + { + if (! $this->isEnabled()) { + return $callback(); + } + + $fullKey = $this->key($workspace, $key); + $ttl = $ttl ?? $this->defaultTtl(); + + // Register the key for later cleanup + $this->registerKey($workspace, $fullKey); + + if ($this->supportsTags()) { + return Cache::tags([$this->workspaceTag($workspace)]) + ->remember($fullKey, $ttl, $callback); + } + + return Cache::remember($fullKey, $ttl, $callback); + } + + /** + * Remember a value forever in the cache for a workspace. + * + * @template T + * + * @param Closure(): T $callback + * @return T + */ + public function rememberForever(Workspace|int $workspace, string $key, Closure $callback): mixed + { + if (! $this->isEnabled()) { + return $callback(); + } + + $fullKey = $this->key($workspace, $key); + + // Register the key for later cleanup + $this->registerKey($workspace, $fullKey); + + if ($this->supportsTags()) { + return Cache::tags([$this->workspaceTag($workspace)]) + ->rememberForever($fullKey, $callback); + } + + return Cache::rememberForever($fullKey, $callback); + } + + /** + * Store a value in the cache for a workspace. + */ + public function put(Workspace|int $workspace, string $key, mixed $value, ?int $ttl = null): bool + { + if (! $this->isEnabled()) { + return false; + } + + $fullKey = $this->key($workspace, $key); + $ttl = $ttl ?? $this->defaultTtl(); + + // Register the key for later cleanup + $this->registerKey($workspace, $fullKey); + + if ($this->supportsTags()) { + return Cache::tags([$this->workspaceTag($workspace)]) + ->put($fullKey, $value, $ttl); + } + + return Cache::put($fullKey, $value, $ttl); + } + + /** + * Get a value from the cache. + */ + public function get(Workspace|int $workspace, string $key, mixed $default = null): mixed + { + if (! $this->isEnabled()) { + return $default; + } + + $fullKey = $this->key($workspace, $key); + + if ($this->supportsTags()) { + return Cache::tags([$this->workspaceTag($workspace)]) + ->get($fullKey, $default); + } + + return Cache::get($fullKey, $default); + } + + /** + * Check if a key exists in the cache. + */ + public function has(Workspace|int $workspace, string $key): bool + { + if (! $this->isEnabled()) { + return false; + } + + $fullKey = $this->key($workspace, $key); + + if ($this->supportsTags()) { + return Cache::tags([$this->workspaceTag($workspace)]) + ->has($fullKey); + } + + return Cache::has($fullKey); + } + + /** + * Remove a specific key from the cache. + */ + public function forget(Workspace|int $workspace, string $key): bool + { + $fullKey = $this->key($workspace, $key); + + // Unregister the key + $this->unregisterKey($workspace, $fullKey); + + if ($this->supportsTags()) { + return Cache::tags([$this->workspaceTag($workspace)]) + ->forget($fullKey); + } + + return Cache::forget($fullKey); + } + + /** + * Flush all cache for a specific workspace. + */ + public function flush(Workspace|int $workspace): bool + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + if ($this->supportsTags()) { + $result = Cache::tags([$this->workspaceTag($workspace)])->flush(); + $this->clearKeyRegistry($workspaceId); + + return $result; + } + + // For non-tagged stores, we need to clear each registered key + return $this->flushRegisteredKeys($workspaceId); + } + + /** + * Flush cache for a specific model across all workspaces. + * Useful when a model's caching logic changes. + */ + public function flushModel(string $modelClass): bool + { + if ($this->supportsTags()) { + return Cache::tags([$this->modelTag($modelClass)])->flush(); + } + + // For non-tagged stores, we would need to track model-specific keys + // This is a best-effort operation + Log::warning("WorkspaceCacheManager: Cannot flush model cache without tags for {$modelClass}"); + + return false; + } + + /** + * Remember a model collection for a workspace with proper tagging. + * + * @template T + * + * @param Closure(): T $callback + * @return T + */ + public function rememberModel( + Workspace|int $workspace, + string $modelClass, + string $key, + ?int $ttl, + Closure $callback + ): mixed { + if (! $this->isEnabled()) { + return $callback(); + } + + $fullKey = $this->key($workspace, $key); + $ttl = $ttl ?? $this->defaultTtl(); + + // Register the key for later cleanup + $this->registerKey($workspace, $fullKey); + + if ($this->supportsTags()) { + return Cache::tags([ + $this->workspaceTag($workspace), + $this->modelTag($modelClass), + ])->remember($fullKey, $ttl, $callback); + } + + return Cache::remember($fullKey, $ttl, $callback); + } + + /** + * Get cache statistics for a workspace. + * + * This is useful for debugging and monitoring cache usage. + */ + public function stats(Workspace|int $workspace): array + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + $keys = self::$keyRegistry[$workspaceId] ?? []; + + $stats = [ + 'workspace_id' => $workspaceId, + 'enabled' => $this->isEnabled(), + 'supports_tags' => $this->supportsTags(), + 'prefix' => $this->prefix(), + 'default_ttl' => $this->defaultTtl(), + 'registered_keys' => count($keys), + 'keys' => $keys, + ]; + + // If we can, check which keys actually exist in cache + $existingKeys = 0; + foreach ($keys as $key) { + if (Cache::has($key)) { + $existingKeys++; + } + } + $stats['existing_keys'] = $existingKeys; + + return $stats; + } + + /** + * Get all registered keys for a workspace. + */ + public function getRegisteredKeys(Workspace|int $workspace): array + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return self::$keyRegistry[$workspaceId] ?? []; + } + + /** + * Register a cache key for a workspace. + * This allows us to track all keys for cleanup later. + */ + protected function registerKey(Workspace|int $workspace, string $key): void + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + if (! isset(self::$keyRegistry[$workspaceId])) { + self::$keyRegistry[$workspaceId] = []; + } + + if (! in_array($key, self::$keyRegistry[$workspaceId], true)) { + self::$keyRegistry[$workspaceId][] = $key; + } + } + + /** + * Unregister a cache key for a workspace. + */ + protected function unregisterKey(Workspace|int $workspace, string $key): void + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + if (isset(self::$keyRegistry[$workspaceId])) { + self::$keyRegistry[$workspaceId] = array_filter( + self::$keyRegistry[$workspaceId], + fn ($k) => $k !== $key + ); + } + } + + /** + * Clear the key registry for a workspace. + */ + protected function clearKeyRegistry(int $workspaceId): void + { + unset(self::$keyRegistry[$workspaceId]); + } + + /** + * Flush all registered keys for a workspace (non-tagged stores). + */ + protected function flushRegisteredKeys(int $workspaceId): bool + { + $keys = self::$keyRegistry[$workspaceId] ?? []; + + foreach ($keys as $key) { + Cache::forget($key); + } + + $this->clearKeyRegistry($workspaceId); + + return true; + } + + /** + * Reset the key registry (useful for testing). + */ + public static function resetKeyRegistry(): void + { + self::$keyRegistry = []; + } + + /** + * Override configuration (useful for testing). + */ + public function setConfig(array $config): void + { + $this->config = $config; + } +} diff --git a/src/Services/WorkspaceManager.php b/src/Services/WorkspaceManager.php new file mode 100644 index 0000000..f3b9a48 --- /dev/null +++ b/src/Services/WorkspaceManager.php @@ -0,0 +1,221 @@ +attributes->set('workspace_model', $workspace); + + // Also cache it for quick retrieval + Cache::put("workspace.current.{$workspace->id}", $workspace, now()->addMinutes(5)); + } + + /** + * Forget the current workspace from request context. + */ + public function forgetCurrent(): void + { + if (request()->attributes->has('workspace_model')) { + $workspace = request()->attributes->get('workspace_model'); + Cache::forget("workspace.current.{$workspace->id}"); + request()->attributes->remove('workspace_model'); + } + } + + /** + * Get the current workspace. + */ + public function current(): ?Workspace + { + return Workspace::current(); + } + + /** + * Get all workspaces for the authenticated user. + */ + public function all(): Collection|array + { + if (! auth()->check()) { + return collect([]); + } + + /** @var User $user */ + $user = auth()->user(); + + return $user instanceof User + ? $user->workspaces + : collect([]); + } + + /** + * Load workspace by ID and set as current. + */ + public function loadById(int $id): bool + { + $workspace = Workspace::find($id); + + if (! $workspace) { + return false; + } + + $this->setCurrent($workspace); + + return true; + } + + /** + * Load workspace by UUID and set as current. + */ + public function loadByUuid(string $uuid): bool + { + $workspace = Workspace::where('uuid', $uuid)->first(); + + if (! $workspace) { + return false; + } + + $this->setCurrent($workspace); + + return true; + } + + /** + * Load workspace by slug and set as current. + */ + public function loadBySlug(string $slug): bool + { + $workspace = Workspace::where('slug', $slug)->first(); + + if (! $workspace) { + return false; + } + + $this->setCurrent($workspace); + + return true; + } + + /** + * Get unique validation rule for a column scoped to workspace. + * + * This ensures uniqueness within a workspace context (e.g., account names, + * template titles) rather than globally. + */ + public function uniqueRule(string $table, string $column = 'id', bool $softDelete = false): Rule + { + $workspace = $this->current(); + + $rule = Rule::unique($table, $column); + + if ($workspace) { + $rule->where('workspace_id', $workspace->id); + } + + if ($softDelete) { + $rule->whereNull('deleted_at'); + } + + return $rule; + } + + /** + * Get exists validation rule for a column scoped to workspace. + */ + public function existsRule(string $table, string $column = 'id', bool $softDelete = false): Rule + { + $workspace = $this->current(); + + $rule = Rule::exists($table, $column); + + if ($workspace) { + $rule->where('workspace_id', $workspace->id); + } + + if ($softDelete) { + $rule->whereNull('deleted_at'); + } + + return $rule; + } + + /** + * Create a new workspace for a user. + */ + public function create(User $user, array $attributes): Workspace + { + $workspace = Workspace::create($attributes); + + // Attach user as owner + $workspace->users()->attach($user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + return $workspace; + } + + /** + * Add a user to a workspace. + */ + public function addUser(Workspace $workspace, User $user, string $role = 'member', bool $isDefault = false): void + { + $workspace->users()->syncWithoutDetaching([ + $user->id => [ + 'role' => $role, + 'is_default' => $isDefault, + ], + ]); + } + + /** + * Remove a user from a workspace. + */ + public function removeUser(Workspace $workspace, User $user): void + { + $workspace->users()->detach($user->id); + } + + /** + * Switch user's default workspace. + */ + public function setDefault(User $user, Workspace $workspace): void + { + // Remove default flag from all workspaces + $user->workspaces()->updateExistingPivot( + $user->workspaces()->pluck('workspaces.id')->toArray(), + ['is_default' => false] + ); + + // Set this one as default + $user->workspaces()->updateExistingPivot($workspace->id, ['is_default' => true]); + } + + /** + * Check if workspace has capacity for new resources. + */ + public function hasCapacity(Workspace $workspace, string $featureCode, int $quantity = 1): bool + { + return $workspace->can($featureCode, $quantity)->isAllowed(); + } +} diff --git a/src/Services/WorkspaceService.php b/src/Services/WorkspaceService.php new file mode 100644 index 0000000..30d08fc --- /dev/null +++ b/src/Services/WorkspaceService.php @@ -0,0 +1,156 @@ + + */ + public function all(): array + { + $user = auth()->user(); + if (! $user) { + return []; + } + + return $user->workspaces() + ->active() + ->ordered() + ->get() + ->keyBy('slug') + ->map(fn (Workspace $w) => $w->toServiceArray()) + ->toArray(); + } + + /** + * Get the current workspace slug from session. + */ + public function currentSlug(): string + { + return Session::get('workspace', 'main'); + } + + /** + * Get the current workspace as array. + */ + public function current(): array + { + $workspace = $this->currentModel(); + + return $workspace?->toServiceArray() ?? [ + 'name' => 'No Workspace', + 'slug' => 'main', + 'domain' => '', + 'icon' => 'globe', + 'color' => 'zinc', + 'description' => 'Select a workspace', + ]; + } + + /** + * Get the current workspace model. + */ + public function currentModel(): ?Workspace + { + $slug = $this->currentSlug(); + $user = auth()->user(); + + if (! $user) { + return null; + } + + // Try to find in user's workspaces + $workspace = $user->workspaces()->where('slug', $slug)->first(); + + // Fall back to default workspace + if (! $workspace) { + $workspace = $user->workspaces()->wherePivot('is_default', true)->first() + ?? $user->workspaces()->first(); + + if ($workspace) { + Session::put('workspace', $workspace->slug); + } + } + + return $workspace; + } + + /** + * Set the current workspace by slug. + */ + public function setCurrent(string $slug): bool + { + $user = auth()->user(); + if (! $user) { + return false; + } + + // Verify user has access to this workspace + $workspace = $user->workspaces()->where('slug', $slug)->first(); + if (! $workspace) { + return false; + } + + Session::put('workspace', $slug); + + return true; + } + + /** + * Get a specific workspace by slug (as array). + */ + public function get(string $slug): ?array + { + $workspace = Workspace::where('slug', $slug)->first(); + + return $workspace?->toServiceArray(); + } + + /** + * Get a workspace model by slug. + */ + public function getModel(string $slug): ?Workspace + { + return Workspace::where('slug', $slug)->first(); + } + + /** + * Find workspace by subdomain. + */ + public function findBySubdomain(string $subdomain): ?array + { + // Check for exact slug match first + $workspace = Workspace::where('slug', $subdomain)->first(); + if ($workspace) { + return $workspace->toServiceArray(); + } + + // Check domain contains subdomain + $workspace = Workspace::where('domain', 'LIKE', "{$subdomain}.%")->first(); + + return $workspace?->toServiceArray(); + } + + /** + * Get workspace slug from subdomain. + */ + public function getSlugFromSubdomain(string $subdomain): ?string + { + $workspace = $this->findBySubdomain($subdomain); + + return $workspace['slug'] ?? null; + } +} diff --git a/src/Services/WorkspaceTeamService.php b/src/Services/WorkspaceTeamService.php new file mode 100644 index 0000000..34dcf0e --- /dev/null +++ b/src/Services/WorkspaceTeamService.php @@ -0,0 +1,629 @@ +workspace = $workspace; + } + + /** + * Set the workspace context. + */ + public function forWorkspace(Workspace $workspace): self + { + $this->workspace = $workspace; + + return $this; + } + + /** + * Get the current workspace, resolving from context if needed. + */ + protected function getWorkspace(): ?Workspace + { + if ($this->workspace) { + return $this->workspace; + } + + // Try authenticated user's default workspace first + $this->workspace = auth()->user()?->defaultHostWorkspace(); + + // Fall back to session workspace if set + if (! $this->workspace) { + $sessionWorkspaceId = session('workspace_id'); + if ($sessionWorkspaceId) { + $this->workspace = Workspace::find($sessionWorkspaceId); + } + } + + return $this->workspace; + } + + // ───────────────────────────────────────────────────────────────────────── + // Team Management + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all teams for the workspace. + */ + public function getTeams(): Collection + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return new Collection; + } + + return WorkspaceTeam::where('workspace_id', $workspace->id) + ->ordered() + ->get(); + } + + /** + * Get a specific team by ID. + */ + public function getTeam(int $teamId): ?WorkspaceTeam + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return null; + } + + return WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('id', $teamId) + ->first(); + } + + /** + * Get a specific team by slug. + */ + public function getTeamBySlug(string $slug): ?WorkspaceTeam + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return null; + } + + return WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('slug', $slug) + ->first(); + } + + /** + * Get the default team for new members. + */ + public function getDefaultTeam(): ?WorkspaceTeam + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return null; + } + + return WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('is_default', true) + ->first(); + } + + /** + * Create a new team. + */ + public function createTeam(array $data): WorkspaceTeam + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + throw new \RuntimeException('No workspace context available.'); + } + + $team = WorkspaceTeam::create([ + 'workspace_id' => $workspace->id, + 'name' => $data['name'], + 'slug' => $data['slug'] ?? null, + 'description' => $data['description'] ?? null, + 'permissions' => $data['permissions'] ?? [], + 'is_default' => $data['is_default'] ?? false, + 'is_system' => $data['is_system'] ?? false, + 'colour' => $data['colour'] ?? 'zinc', + 'sort_order' => $data['sort_order'] ?? 0, + ]); + + // If this is the new default, unset other defaults + if ($team->is_default) { + WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('id', '!=', $team->id) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + Log::info('Workspace team created', [ + 'team_id' => $team->id, + 'team_name' => $team->name, + 'workspace_id' => $workspace->id, + ]); + + return $team; + } + + /** + * Update an existing team. + */ + public function updateTeam(WorkspaceTeam $team, array $data): WorkspaceTeam + { + $workspace = $this->getWorkspace(); + + // Don't allow updating system teams' slug + if ($team->is_system && isset($data['slug'])) { + unset($data['slug']); + } + + $team->update($data); + + // If this is the new default, unset other defaults + if (($data['is_default'] ?? false) && $workspace) { + WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('id', '!=', $team->id) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + Log::info('Workspace team updated', [ + 'team_id' => $team->id, + 'team_name' => $team->name, + 'workspace_id' => $team->workspace_id, + ]); + + return $team; + } + + /** + * Delete a team (only non-system teams). + */ + public function deleteTeam(WorkspaceTeam $team): bool + { + if ($team->is_system) { + throw new \RuntimeException('Cannot delete system teams.'); + } + + // Check if team has any members assigned + $memberCount = WorkspaceMember::where('team_id', $team->id)->count(); + if ($memberCount > 0) { + throw new \RuntimeException( + "Cannot delete team with {$memberCount} assigned members. Remove members first." + ); + } + + $teamId = $team->id; + $teamName = $team->name; + $workspaceId = $team->workspace_id; + + $team->delete(); + + Log::info('Workspace team deleted', [ + 'team_id' => $teamId, + 'team_name' => $teamName, + 'workspace_id' => $workspaceId, + ]); + + return true; + } + + // ───────────────────────────────────────────────────────────────────────── + // Member Management + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get a member record for a user in the workspace. + */ + public function getMember(User|int $user): ?WorkspaceMember + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return null; + } + + $userId = $user instanceof User ? $user->id : $user; + + return WorkspaceMember::where('workspace_id', $workspace->id) + ->where('user_id', $userId) + ->first(); + } + + /** + * Get all members in the workspace. + */ + public function getMembers(): Collection + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return new Collection; + } + + return WorkspaceMember::where('workspace_id', $workspace->id) + ->with(['user', 'team', 'inviter']) + ->get(); + } + + /** + * Get all members in a specific team. + */ + public function getTeamMembers(WorkspaceTeam|int $team): Collection + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return new Collection; + } + + $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; + + return WorkspaceMember::where('workspace_id', $workspace->id) + ->where('team_id', $teamId) + ->with(['user', 'team', 'inviter']) + ->get(); + } + + /** + * Add a member to a team. + */ + public function addMemberToTeam(User|int $user, WorkspaceTeam|int $team): WorkspaceMember + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + throw new \RuntimeException('No workspace context available.'); + } + + $userId = $user instanceof User ? $user->id : $user; + $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; + + // Verify team belongs to workspace + $teamModel = WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('id', $teamId) + ->first(); + + if (! $teamModel) { + throw new \RuntimeException('Team does not belong to the current workspace.'); + } + + $member = WorkspaceMember::where('workspace_id', $workspace->id) + ->where('user_id', $userId) + ->first(); + + if (! $member) { + throw new \RuntimeException('User is not a member of this workspace.'); + } + + $member->update(['team_id' => $teamId]); + + Log::info('Member added to team', [ + 'user_id' => $userId, + 'team_id' => $teamId, + 'team_name' => $teamModel->name, + 'workspace_id' => $workspace->id, + ]); + + return $member->fresh(); + } + + /** + * Remove a member from their team. + */ + public function removeMemberFromTeam(User|int $user): WorkspaceMember + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + throw new \RuntimeException('No workspace context available.'); + } + + $userId = $user instanceof User ? $user->id : $user; + + $member = WorkspaceMember::where('workspace_id', $workspace->id) + ->where('user_id', $userId) + ->first(); + + if (! $member) { + throw new \RuntimeException('User is not a member of this workspace.'); + } + + $oldTeamId = $member->team_id; + $member->update(['team_id' => null]); + + Log::info('Member removed from team', [ + 'user_id' => $userId, + 'old_team_id' => $oldTeamId, + 'workspace_id' => $workspace->id, + ]); + + return $member->fresh(); + } + + /** + * Set custom permissions for a member. + */ + public function setMemberCustomPermissions(User|int $user, array $customPermissions): WorkspaceMember + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + throw new \RuntimeException('No workspace context available.'); + } + + $userId = $user instanceof User ? $user->id : $user; + + $member = WorkspaceMember::where('workspace_id', $workspace->id) + ->where('user_id', $userId) + ->first(); + + if (! $member) { + throw new \RuntimeException('User is not a member of this workspace.'); + } + + $member->update(['custom_permissions' => $customPermissions]); + + Log::info('Member custom permissions updated', [ + 'user_id' => $userId, + 'workspace_id' => $workspace->id, + 'custom_permissions' => $customPermissions, + ]); + + return $member->fresh(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Permission Checks + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all effective permissions for a user in the workspace. + */ + public function getMemberPermissions(User|int $user): array + { + $member = $this->getMember($user); + + if (! $member) { + return []; + } + + return $member->getEffectivePermissions(); + } + + /** + * Check if a user has a specific permission in the workspace. + */ + public function hasPermission(User|int $user, string $permission): bool + { + $member = $this->getMember($user); + + if (! $member) { + return false; + } + + return $member->hasPermission($permission); + } + + /** + * Check if a user has any of the given permissions. + */ + public function hasAnyPermission(User|int $user, array $permissions): bool + { + $member = $this->getMember($user); + + if (! $member) { + return false; + } + + return $member->hasAnyPermission($permissions); + } + + /** + * Check if a user has all of the given permissions. + */ + public function hasAllPermissions(User|int $user, array $permissions): bool + { + $member = $this->getMember($user); + + if (! $member) { + return false; + } + + return $member->hasAllPermissions($permissions); + } + + /** + * Check if a user is the workspace owner. + */ + public function isOwner(User|int $user): bool + { + $member = $this->getMember($user); + + return $member?->isOwner() ?? false; + } + + /** + * Check if a user is a workspace admin. + */ + public function isAdmin(User|int $user): bool + { + $member = $this->getMember($user); + + return $member?->isAdmin() ?? false; + } + + // ───────────────────────────────────────────────────────────────────────── + // Member Queries + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get members with a specific permission. + */ + public function getMembersWithPermission(string $permission): Collection + { + $members = $this->getMembers(); + + return $members->filter(fn ($member) => $member->hasPermission($permission)); + } + + /** + * Count members in the workspace. + */ + public function countMembers(): int + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return 0; + } + + return WorkspaceMember::where('workspace_id', $workspace->id)->count(); + } + + /** + * Count members in a specific team. + */ + public function countTeamMembers(WorkspaceTeam|int $team): int + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return 0; + } + + $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; + + return WorkspaceMember::where('workspace_id', $workspace->id) + ->where('team_id', $teamId) + ->count(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Seeding + // ───────────────────────────────────────────────────────────────────────── + + /** + * Seed default teams for a workspace. + */ + public function seedDefaultTeams(?Workspace $workspace = null): Collection + { + $workspace = $workspace ?? $this->getWorkspace(); + if (! $workspace) { + throw new \RuntimeException('No workspace context available for seeding.'); + } + + $teams = new Collection; + + foreach (WorkspaceTeam::getDefaultTeamDefinitions() as $definition) { + // Check if team already exists + $existing = WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('slug', $definition['slug']) + ->first(); + + if ($existing) { + $teams->push($existing); + + continue; + } + + $team = WorkspaceTeam::create([ + 'workspace_id' => $workspace->id, + 'name' => $definition['name'], + 'slug' => $definition['slug'], + 'description' => $definition['description'], + 'permissions' => $definition['permissions'], + 'is_default' => $definition['is_default'] ?? false, + 'is_system' => $definition['is_system'] ?? false, + 'colour' => $definition['colour'] ?? 'zinc', + 'sort_order' => $definition['sort_order'] ?? 0, + ]); + + $teams->push($team); + } + + Log::info('Default workspace teams seeded', [ + 'workspace_id' => $workspace->id, + 'teams_count' => $teams->count(), + ]); + + return $teams; + } + + /** + * Ensure default teams exist for the workspace, creating them if needed. + */ + public function ensureDefaultTeams(): Collection + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return new Collection; + } + + // Check if any teams exist + $existingCount = WorkspaceTeam::where('workspace_id', $workspace->id)->count(); + + if ($existingCount === 0) { + return $this->seedDefaultTeams($workspace); + } + + return $this->getTeams(); + } + + /** + * Migrate existing members to appropriate teams based on their role. + */ + public function migrateExistingMembers(): int + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return 0; + } + + // Ensure teams exist + $this->ensureDefaultTeams(); + + $ownerTeam = $this->getTeamBySlug(WorkspaceTeam::TEAM_OWNER); + $adminTeam = $this->getTeamBySlug(WorkspaceTeam::TEAM_ADMIN); + $memberTeam = $this->getTeamBySlug(WorkspaceTeam::TEAM_MEMBER); + + $migrated = 0; + + DB::transaction(function () use ($workspace, $ownerTeam, $adminTeam, $memberTeam, &$migrated) { + // Get members without team assignments + $members = WorkspaceMember::where('workspace_id', $workspace->id) + ->whereNull('team_id') + ->get(); + + foreach ($members as $member) { + $teamId = match ($member->role) { + WorkspaceMember::ROLE_OWNER => $ownerTeam?->id, + WorkspaceMember::ROLE_ADMIN => $adminTeam?->id, + default => $memberTeam?->id, + }; + + if ($teamId) { + $member->update([ + 'team_id' => $teamId, + 'joined_at' => $member->joined_at ?? $member->created_at, + ]); + $migrated++; + } + } + }); + + Log::info('Workspace members migrated to teams', [ + 'workspace_id' => $workspace->id, + 'migrated_count' => $migrated, + ]); + + return $migrated; + } +} diff --git a/src/View/Blade/admin/entitlement-webhook-manager.blade.php b/src/View/Blade/admin/entitlement-webhook-manager.blade.php new file mode 100644 index 0000000..d852625 --- /dev/null +++ b/src/View/Blade/admin/entitlement-webhook-manager.blade.php @@ -0,0 +1,401 @@ +
+ {{-- Stats --}} +
+ +
+
+ +
+
+
{{ number_format($this->stats['total']) }}
+
Total Webhooks
+
+
+
+ + +
+
+ +
+
+
{{ number_format($this->stats['active']) }}
+
Active
+
+
+
+ + +
+
+ +
+
+
{{ number_format($this->stats['circuit_broken']) }}
+
Circuit Broken
+
+
+
+
+ + {{-- Message --}} + @if($message) +
+ + {{ $message }} + +
+ @endif + + {{-- Filters --}} + +
+
+ +
+ +
+ + + @foreach($this->workspaces as $workspace) + + @endforeach + +
+ +
+ + + + + + +
+ + + + New Webhook + +
+
+ + {{-- Webhooks Table --}} + + + + + Webhook + Workspace + Events + Status + Deliveries + Actions + + + + @forelse($this->webhooks as $webhook) + + +
+
{{ $webhook->name }}
+
+ {{ $webhook->url }} +
+
+
+ + + {{ $webhook->workspace?->name ?? 'N/A' }} + + + +
+ @foreach($webhook->events as $event) + {{ $event }} + @endforeach +
+
+ + + @if($webhook->isCircuitBroken()) + Circuit Broken + @elseif($webhook->is_active) + Active + @else + Inactive + @endif + + @if($webhook->last_delivery_status) +
+ + Last: {{ ucfirst($webhook->last_delivery_status->value) }} + +
+ @endif +
+ + + + + + + + + + + + + + + + Edit + + + + + Send Test + + + + + View Deliveries + + + + + Regenerate Secret + + + @if($webhook->isCircuitBroken()) + + + Reset Circuit Breaker + + @endif + + + + + @if($webhook->is_active) + + Disable + @else + + Enable + @endif + + + + + Delete + + + +
+ @empty + + + No webhooks found. Create one to get started. + + + @endforelse +
+
+ +
+ {{ $this->webhooks->links() }} +
+
+ + {{-- Create/Edit Modal --}} + + + {{ $editingId ? 'Edit Webhook' : 'Create Webhook' }} + + + +
+ @if(!$editingId) + + Workspace + + + @foreach($this->workspaces as $workspace) + + @endforeach + + + + @endif + + + Name + + + + + + URL + + + The endpoint that will receive webhook POST requests. + + + + Events +
+ @foreach($this->availableEvents as $eventKey => $eventInfo) + + @endforeach +
+ +
+ + + Max Retry Attempts + + Number of times to retry failed deliveries (1-10). + + + + + Inactive webhooks will not receive any events. + +
+
+ + + Cancel + + {{ $editingId ? 'Update' : 'Create' }} + + +
+ + {{-- Deliveries Modal --}} + + + Delivery History + + + + + + + Event + Status + HTTP + Attempts + Time + + + + + @forelse($this->recentDeliveries as $delivery) + + + {{ $delivery->getEventDisplayName() }} + + + + + {{ ucfirst($delivery->status->value) }} + + + + + {{ $delivery->http_status ?? '-' }} + + + + {{ $delivery->attempts }} + + + + + {{ $delivery->created_at->diffForHumans() }} + + + + + @if($delivery->isFailed()) + + Retry + + @endif + + + @empty + + + No deliveries yet. + + + @endforelse + + + + + + Close + + + + {{-- Secret Modal --}} + + + Webhook Secret + + + + + Save this secret now. It will not be shown again. + + +
+ {{ $displaySecret }} +
+ +

+ Use this secret to verify webhook signatures. The signature is sent in the + X-Signature header + and is a HMAC-SHA256 hash of the JSON payload. +

+
+ + + + I've saved the secret + + +
+
diff --git a/src/View/Blade/admin/workspace-details.blade.php b/src/View/Blade/admin/workspace-details.blade.php new file mode 100644 index 0000000..1c5bf09 --- /dev/null +++ b/src/View/Blade/admin/workspace-details.blade.php @@ -0,0 +1,696 @@ +
+ {{-- Header --}} +
+
+ + + Workspaces + + / + {{ $workspace->name }} +
+ +
+
+
+ +
+
+

{{ $workspace->name }}

+
+ {{ $workspace->slug }} + @if($workspace->is_active) + + Active + + @else + + Inactive + + @endif +
+
+
+ + + Hades Only + +
+
+ + {{-- Action message --}} + @if($actionMessage) +
+
+ + {{ $actionMessage }} +
+
+ @endif + + {{-- Tabs --}} +
+ +
+ + {{-- Tab Content --}} +
+ {{-- Overview Tab --}} + @if($activeTab === 'overview') +
+ {{-- Quick Stats --}} +
+ {{-- Owner Card --}} +
+

Workspace Owner

+ @php $owner = $workspace->owner(); @endphp + @if($owner) +
+
+ +
+
+
{{ $owner->name }}
+
{{ $owner->email }}
+
+
+ @else +
No owner assigned
+ @endif +
+ + {{-- Packages Card --}} +
+

Active Packages

+ @if($this->activePackages->count() > 0) +
+ @foreach($this->activePackages as $wp) +
+
+
+ +
+
+
{{ $wp->package?->name ?? 'Unknown' }}
+
{{ $wp->package?->code ?? '' }}
+
+
+
+ + {{ ucfirst($wp->status) }} + + @if($wp->expires_at) +
Expires {{ $wp->expires_at->format('d M Y') }}
+ @endif +
+
+ @endforeach +
+ @else +
No packages assigned
+ @endif +
+ + {{-- Boosts Card --}} +
+

Active Boosts

+ @if($this->activeBoosts->count() > 0) +
+ @foreach($this->activeBoosts as $boost) +
+
+
+ +
+
+
{{ $boost->feature_code }}
+
{{ str_replace('_', ' ', $boost->boost_type) }}@if($boost->limit_value) · +{{ number_format($boost->limit_value) }}@endif
+
+
+
+ + {{ ucfirst($boost->status) }} + + @if($boost->expires_at) +
Expires {{ $boost->expires_at->format('d M Y') }}
+ @else +
Permanent
+ @endif +
+
+ @endforeach +
+ @else +
No boosts active
+ @endif +
+ + {{-- Subscription Card --}} + @if($this->subscriptionInfo) +
+

Subscription

+
+
+
+ +
+
+
{{ $this->subscriptionInfo['plan'] }}
+
Renews {{ $this->subscriptionInfo['current_period_end'] }}
+
+
+ @if($this->subscriptionInfo['amount']) +
+
{{ $this->subscriptionInfo['currency'] }} {{ $this->subscriptionInfo['amount'] }}
+
/month
+
+ @endif +
+
+ @endif +
+ + {{-- Sidebar Stats --}} +
+
+

Quick Stats

+
+
+ Team Members + {{ $this->teamMembers->count() }} +
+ @foreach(array_slice($this->resourceCounts, 0, 5) as $resource) +
+ {{ $resource['label'] }} + {{ $resource['count'] }} +
+ @endforeach +
+
+ + {{-- Workspace Info --}} +
+

Details

+
+
+
Created
+
{{ $workspace->created_at->format('d M Y') }}
+
+
+
+ Domain + +
+
{{ $workspace->domain ?: 'Not set' }}
+
+ @if($workspace->wp_connector_enabled) +
+
WP Connector
+
+ Connected +
+
+ @endif +
+
+
+
+ @endif + + {{-- Team Tab --}} + @if($activeTab === 'team') +
+
+

Team Members ({{ $this->teamMembers->count() }})

+ + + Add Member + +
+
+ @forelse($this->teamMembers as $member) +
+
+
+ +
+
+
{{ $member->name }}
+
{{ $member->email }}
+
+
+
+ + {{ ucfirst($member->pivot->role) }} + + @if($member->pivot->role !== 'owner') +
+ + +
+ @endif +
+
+ @empty +
+ No team members found. +
+ @endforelse +
+
+ @endif + + {{-- Entitlements Tab --}} + @if($activeTab === 'entitlements') +
+ {{-- Stats Header --}} +
+
+

Entitlement Overview

+
+ + + Add Package + + + + Add Entitlement + +
+
+
+
+
{{ $this->entitlementStats['total'] }}
+
Total Features
+
+
+
{{ $this->entitlementStats['allowed'] }}
+
Allowed
+
+
+
{{ $this->entitlementStats['denied'] }}
+
Not Included
+
+
+
{{ $this->entitlementStats['near_limit'] }}
+
Near Limit
+
+
+
{{ $this->entitlementStats['packages'] }}
+
Packages
+
+
+
{{ $this->entitlementStats['boosts'] }}
+
Boosts
+
+
+
+ + {{-- Active Boosts Section --}} + @if($this->activeBoosts->count() > 0) +
+
+

+ + Active Boosts ({{ $this->activeBoosts->count() }}) +

+
+
+ @foreach($this->activeBoosts as $boost) +
+
+
+ +
+
+
{{ $boost->feature_code }}
+
+ {{ str_replace('_', ' ', $boost->boost_type) }} + @if($boost->limit_value) + · +{{ number_format($boost->limit_value) }} + @endif + @if($boost->expires_at) + + · Expires {{ $boost->expires_at->format('d M Y') }} + + @else + · Permanent + @endif +
+
+
+ +
+ @endforeach +
+
+ @endif + + {{-- Resolved Entitlements by Category --}} + @forelse($this->resolvedEntitlements as $category => $features) +
+
+

{{ $category ?: 'General' }}

+
+
+ @foreach($features as $entitlement) +
+
+ {{-- Status indicator --}} +
+ +
+
+
{{ $entitlement['name'] }}
+
{{ $entitlement['code'] }}
+
+
+ +
+ {{-- Type badge --}} + + @if($entitlement['type'] === 'boolean') + Toggle + @elseif($entitlement['unlimited']) + Unlimited + @else + Limit + @endif + + + {{-- Usage info --}} + @if($entitlement['type'] !== 'boolean' && !$entitlement['unlimited'] && $entitlement['allowed']) +
+
+ {{ number_format($entitlement['used'] ?? 0) }} + / {{ number_format($entitlement['limit']) }} +
+ @if($entitlement['limit'] > 0) + @php $percent = $entitlement['percentage'] ?? 0; @endphp +
+
+
+ @endif +
+ @elseif($entitlement['unlimited']) +
+ + + {{ number_format($entitlement['used'] ?? 0) }} used + +
+ @elseif(!$entitlement['allowed']) +
+ Not included +
+ @endif +
+
+ @endforeach +
+
+ @empty +
+ No entitlements configured. +
+ @endforelse + + {{-- Packages Section --}} + @if($this->workspacePackages->count() > 0) +
+
+

+ + Assigned Packages ({{ $this->workspacePackages->count() }}) +

+
+
+ @foreach($this->workspacePackages as $wp) +
+
+
+ +
+
+
{{ $wp->package?->name ?? 'Unknown' }}
+
+ {{ $wp->package?->code ?? '' }} + @if($wp->expires_at) + + · Expires {{ $wp->expires_at->format('d M Y') }} + + @endif +
+
+
+
+ + {{ ucfirst($wp->status) }} + + @if($wp->status === 'active') + + @elseif($wp->status === 'suspended') + + @endif + +
+
+ @endforeach +
+
+ @endif +
+ @endif + + {{-- Resources Tab --}} + @if($activeTab === 'resources') +
+ @foreach($this->resourceCounts as $resource) +
+
+
+ +
+
+
{{ number_format($resource['count']) }}
+
{{ $resource['label'] }}
+
+ @endforeach +
+ + @if(count($this->resourceCounts) === 0) +
+ No resources configured for this workspace. +
+ @endif + @endif + + {{-- Activity Tab --}} + @if($activeTab === 'activity') +
+
+

Recent Activity

+
+
+ @forelse($this->recentActivity as $activity) +
+
+ +
+
+
{{ $activity['message'] }}
+ @if($activity['detail']) +
{{ $activity['detail'] }}
+ @endif +
{{ $activity['created_at']->diffForHumans() }}
+
+
+ @empty +
+ No recent activity found. +
+ @endforelse +
+
+ @endif +
+ + {{-- Add Member Modal --}} + + Add Team Member + +
+ + Select user... + @foreach($this->availableUsers as $user) + {{ $user->name }} ({{ $user->email }}) + @endforeach + + + + Member + Admin + Owner + + +
+ Cancel + Add Member +
+
+
+ + {{-- Edit Member Modal --}} + + Edit Member Role + +
+ + Member + Admin + Owner + + +
+ + Changing to Owner will transfer ownership from the current owner. + +
+ +
+ Cancel + Update Role +
+
+
+ + {{-- Edit Domain Modal --}} + + Edit Domain + +
+ + +
+ + Enter the domain without the protocol (e.g., example.com not https://example.com). + +
+ +
+ Cancel + Save Domain +
+
+
+ + {{-- Add Package Modal --}} + + Add Package + +
+ + @foreach($this->allPackages as $package) + + {{ $package->name }} ({{ $package->code }}) + + @endforeach + + +
+ + The package will be assigned immediately with no expiry date. You can modify or remove it later. + +
+ +
+ Cancel + Add Package +
+
+
+ + {{-- Add Entitlement Modal --}} + + Add Entitlement + +
+ + @foreach($this->allFeatures->groupBy('category') as $category => $features) + ── {{ ucfirst($category ?: 'General') }} ── + @foreach($features as $feature) + + {{ $feature->name }} ({{ $feature->code }}) + + @endforeach + @endforeach + + + + Enable (Toggle on) + Add Limit (Extra quota) + Unlimited + + + @if($entitlementType === 'add_limit') + + @endif + + + Permanent + Expires on date + + + @if($entitlementDuration === 'duration') + + @endif + +
+ + This will create a boost that grants the selected feature directly to this workspace, independent of packages. + +
+ +
+ Cancel + Add Entitlement +
+
+
+
diff --git a/src/View/Blade/admin/workspace-manager.blade.php b/src/View/Blade/admin/workspace-manager.blade.php new file mode 100644 index 0000000..63bb9de --- /dev/null +++ b/src/View/Blade/admin/workspace-manager.blade.php @@ -0,0 +1,417 @@ + + + + + {{ __('tenant::tenant.admin.hades_only') }} + + + + {{-- Action message --}} + @if($actionMessage) +
+
+ + {{ $actionMessage }} +
+
+ @endif + + {{-- Stats Grid --}} +
+
+
{{ number_format($stats['total']) }}
+
{{ __('tenant::tenant.admin.stats.total') }}
+
+
+
{{ number_format($stats['active']) }}
+
{{ __('tenant::tenant.admin.stats.active') }}
+
+
+
{{ number_format($stats['inactive']) }}
+
{{ __('tenant::tenant.admin.stats.inactive') }}
+
+
+ + {{-- Search --}} + + + + + + {{-- Workspace Table --}} +
+
+ + + + + + + + + + + + + + + + + @forelse($this->workspaces as $workspace) + + + + + + + + + + + + + @empty + + + + @endforelse + +
{{ __('tenant::tenant.admin.table.workspace') }}{{ __('tenant::tenant.admin.table.owner') }}{{ __('tenant::tenant.admin.table.bio') }}{{ __('tenant::tenant.admin.table.social') }}{{ __('tenant::tenant.admin.table.analytics') }}{{ __('tenant::tenant.admin.table.trust') }}{{ __('tenant::tenant.admin.table.notify') }}{{ __('tenant::tenant.admin.table.commerce') }}{{ __('tenant::tenant.admin.table.status') }}{{ __('tenant::tenant.admin.table.actions') }}
+ +
+ +
+
+
{{ $workspace->name }}
+
{{ $workspace->slug }}
+
+
+
+ @php $owner = $workspace->owner(); @endphp + @if($owner) +
{{ $owner->name }}
+
{{ $owner->email }}
+ @else + {{ __('tenant::tenant.admin.table.no_owner') }} + @endif +
+ @php + $bioPages = $workspace->bio_pages_count ?? 0; + $bioProjects = $workspace->bio_projects_count ?? 0; + @endphp + @if($bioPages > 0 || $bioProjects > 0) + + @else + + @endif + + @if(($workspace->social_accounts_count ?? 0) > 0) + + @else + + @endif + + @if(($workspace->analytics_sites_count ?? 0) > 0) + + @else + + @endif + + @if(($workspace->trust_widgets_count ?? 0) > 0) + + @else + + @endif + + @if(($workspace->notification_sites_count ?? 0) > 0) + + @else + + @endif + + - + + @if($workspace->is_active) + + {{ __('tenant::tenant.admin.table.active') }} + + @else + + {{ __('tenant::tenant.admin.table.inactive') }} + + @endif + +
+ + + + + + + +
+
+ {{ __('tenant::tenant.admin.table.empty') }} +
+
+ @if($this->workspaces->hasPages()) +
+ {{ $this->workspaces->links() }} +
+ @endif +
+ + {{-- Edit Workspace Modal --}} + + {{ __('tenant::tenant.admin.edit_modal.title') }} + +
+ + + +
+ + {{ __('tenant::tenant.admin.edit_modal.active') }} +
+ +
+ {{ __('tenant::tenant.admin.edit_modal.cancel') }} + {{ __('tenant::tenant.admin.edit_modal.save') }} +
+ +
+ + {{-- Transfer Resources Modal --}} + + {{ __('tenant::tenant.admin.transfer_modal.title') }} + +
+ @if($sourceWorkspaceId) + @php $sourceWorkspace = $this->allWorkspaces->firstWhere('id', $sourceWorkspaceId); @endphp +
+ + {{ __('tenant::tenant.admin.transfer_modal.source') }}: {{ $sourceWorkspace?->name ?? 'Unknown' }} ({{ $sourceWorkspace?->slug ?? '' }}) + +
+ @endif + + + {{ __('tenant::tenant.admin.transfer_modal.select_target') }} + @foreach($this->allWorkspaces as $ws) + @if($ws->id !== $sourceWorkspaceId) + {{ $ws->name }} ({{ $ws->slug }}) + @endif + @endforeach + + + + {{ __('tenant::tenant.admin.transfer_modal.resources_label') }} +
+ @foreach($this->resourceTypes as $key => $type) + + @endforeach +
+
+ +
+ + {{ __('tenant::tenant.admin.transfer_modal.warning') }} + +
+ +
+ {{ __('tenant::tenant.admin.transfer_modal.cancel') }} + + {{ __('tenant::tenant.admin.transfer_modal.transfer') }} + +
+
+
+ + {{-- Change Owner Modal --}} + + {{ __('tenant::tenant.admin.owner_modal.title') }} + +
+ @if($ownerWorkspaceId) + @php $ownerWorkspace = $this->allWorkspaces->firstWhere('id', $ownerWorkspaceId); @endphp +
+ + {{ __('tenant::tenant.admin.owner_modal.workspace') }}: {{ $ownerWorkspace?->name ?? 'Unknown' }} + +
+ @endif + + + {{ __('tenant::tenant.admin.owner_modal.select_owner') }} + @foreach($this->allUsers as $user) + {{ $user->name }} ({{ $user->email }}) + @endforeach + + +
+ + {{ __('tenant::tenant.admin.owner_modal.warning') }} + +
+ +
+ {{ __('tenant::tenant.admin.owner_modal.cancel') }} + + {{ __('tenant::tenant.admin.owner_modal.change') }} + +
+
+
+ + {{-- Resource Viewer Modal --}} + + @php + $resourceWorkspace = $this->allWorkspaces->firstWhere('id', $resourcesWorkspaceId); + $resourceTypeInfo = $this->resourceTypes[$resourcesType] ?? null; + @endphp + + {{ $resourceTypeInfo['label'] ?? 'Resources' }} + {{ __('tenant::tenant.admin.resources_modal.in') }} {{ $resourceWorkspace?->name ?? 'Unknown' }} + + +
+ {{-- Selection controls --}} +
+
+ {{ __('tenant::tenant.admin.resources_modal.select_all') }} + {{ __('tenant::tenant.admin.resources_modal.deselect_all') }} +
+ {{ __('tenant::tenant.admin.resources_modal.selected', ['count' => count($selectedResources)]) }} +
+ + {{-- Resource list --}} +
+ @forelse($this->currentResources as $resource) +
+
+
+ @if(in_array($resource['id'], $selectedResources)) + + @endif +
+
+
+
{{ $resource['name'] }}
+ @if($resource['detail']) +
{{ $resource['detail'] }}
+ @endif +
+
+ {{ $resource['created_at'] }} +
+
+ @empty +
{{ __('tenant::tenant.admin.resources_modal.no_resources') }}
+ @endforelse +
+ + {{-- Transfer section --}} + @if(count($this->currentResources) > 0) +
+ {{ __('tenant::tenant.admin.resources_modal.transfer_selected') }} +
+
+ + {{ __('tenant::tenant.admin.resources_modal.select_workspace') }} + @foreach($this->allWorkspaces as $ws) + @if($ws->id !== $resourcesWorkspaceId) + {{ $ws->name }} ({{ $ws->slug }}) + @endif + @endforeach + +
+ + {{ trans_choice('tenant::tenant.admin.resources_modal.transfer_items', count($selectedResources), ['count' => count($selectedResources)]) }} + +
+
+ @endif + +
+ {{ __('tenant::tenant.admin.resources_modal.close') }} +
+
+
+ + {{-- Provision Resource Modal --}} + + @php + $provisionWorkspace = $this->allWorkspaces->firstWhere('id', $provisionWorkspaceId); + $config = $this->provisionConfig[$provisionType] ?? null; + @endphp + +
+ @if($config) +
+ +
+ @endif + {{ __('tenant::tenant.admin.provision_modal.create', ['type' => $config['label'] ?? 'Resource']) }} +
+
+ +
+ @if($provisionWorkspace) +
+ + {{ __('tenant::tenant.admin.provision_modal.workspace') }}: {{ $provisionWorkspace->name }} + +
+ @endif + + + + @if($config && in_array('slug', $config['fields'] ?? [])) + + @endif + + @if($config && in_array('url', $config['fields'] ?? [])) + + @endif + +
+ {{ __('tenant::tenant.admin.provision_modal.cancel') }} + + {{ __('tenant::tenant.admin.provision_modal.create', ['type' => $config['label'] ?? 'Resource']) }} + +
+
+
+
diff --git a/src/View/Blade/emails/account-deletion-requested.blade.php b/src/View/Blade/emails/account-deletion-requested.blade.php new file mode 100644 index 0000000..72b647e --- /dev/null +++ b/src/View/Blade/emails/account-deletion-requested.blade.php @@ -0,0 +1,44 @@ +@php + $appName = config('core.app.name', __('core::core.brand.name')); +@endphp + + +# {{ __('tenant::tenant.emails.deletion_requested.subject') }} + +{{ __('tenant::tenant.emails.deletion_requested.greeting', ['name' => $user->name]) }} + +{{ __('tenant::tenant.emails.deletion_requested.scheduled', ['app' => $appName]) }} + +**{{ __('tenant::tenant.emails.deletion_requested.auto_delete', ['date' => $expiresAt->format('F j, Y \a\t g:i A'), 'days' => $daysRemaining]) }}** + +**{{ __('tenant::tenant.emails.deletion_requested.will_delete') }}** +- {{ __('tenant::tenant.emails.deletion_requested.items.profile') }} +- {{ __('tenant::tenant.emails.deletion_requested.items.workspaces') }} +- {{ __('tenant::tenant.emails.deletion_requested.items.content') }} +- {{ __('tenant::tenant.emails.deletion_requested.items.social') }} + +**{{ __('tenant::tenant.emails.deletion_requested.delete_now') }}** +{{ __('tenant::tenant.emails.deletion_requested.delete_now_description') }} + + +{{ __('tenant::tenant.emails.deletion_requested.delete_button') }} + + +**{{ __('tenant::tenant.emails.deletion_requested.changed_mind') }}** +{{ __('tenant::tenant.emails.deletion_requested.changed_mind_description') }} + + +{{ __('tenant::tenant.emails.deletion_requested.cancel_button') }} + + +**{{ __('tenant::tenant.emails.deletion_requested.not_requested') }}** +{{ __('tenant::tenant.emails.deletion_requested.not_requested_description') }} + +Thanks,
+{{ $appName }} + + +{{ __('tenant::tenant.emails.deletion_requested.delete_button') }}: {{ $confirmationUrl }}
+{{ __('tenant::tenant.emails.deletion_requested.cancel_button') }}: {{ $cancelUrl }} +
+
diff --git a/src/View/Blade/emails/usage-alert.blade.php b/src/View/Blade/emails/usage-alert.blade.php new file mode 100644 index 0000000..ef8290e --- /dev/null +++ b/src/View/Blade/emails/usage-alert.blade.php @@ -0,0 +1,60 @@ +@php + $appName = config('core.app.name', __('core::core.brand.name')); + $isLimit = $threshold === \Core\Mod\Tenant\Models\UsageAlertHistory::THRESHOLD_LIMIT; + $isCritical = $threshold === \Core\Mod\Tenant\Models\UsageAlertHistory::THRESHOLD_CRITICAL; +@endphp + + +@if($isLimit) +# {{ __('tenant::tenant.emails.usage_alert.limit_reached.heading') }} + +{{ __('tenant::tenant.emails.usage_alert.limit_reached.body', ['workspace' => $workspaceName, 'feature' => $featureName]) }} + +**{{ __('tenant::tenant.emails.usage_alert.limit_reached.usage_line', ['used' => $used, 'limit' => $limit]) }}** + +**{{ __('tenant::tenant.emails.usage_alert.limit_reached.options_heading') }}** +- {{ __('tenant::tenant.emails.usage_alert.limit_reached.options.upgrade') }} +- {{ __('tenant::tenant.emails.usage_alert.limit_reached.options.reset') }} +- {{ __('tenant::tenant.emails.usage_alert.limit_reached.options.reduce') }} + + +{{ __('tenant::tenant.emails.usage_alert.upgrade_plan') }} + + +@elseif($isCritical) +# {{ __('tenant::tenant.emails.usage_alert.critical.heading') }} + +{{ __('tenant::tenant.emails.usage_alert.critical.body', ['workspace' => $workspaceName, 'feature' => $featureName]) }} + +**{{ __('tenant::tenant.emails.usage_alert.critical.usage_line', ['used' => $used, 'limit' => $limit, 'percentage' => $percentage]) }}** + +**{{ __('tenant::tenant.emails.usage_alert.critical.remaining_line', ['remaining' => $remaining]) }}** + +{{ __('tenant::tenant.emails.usage_alert.critical.action_text') }} + + +{{ __('tenant::tenant.emails.usage_alert.upgrade_plan') }} + + +@else +# {{ __('tenant::tenant.emails.usage_alert.warning.heading') }} + +{{ __('tenant::tenant.emails.usage_alert.warning.body', ['workspace' => $workspaceName, 'feature' => $featureName]) }} + +**{{ __('tenant::tenant.emails.usage_alert.warning.usage_line', ['used' => $used, 'limit' => $limit, 'percentage' => $percentage]) }}** + +**{{ __('tenant::tenant.emails.usage_alert.warning.remaining_line', ['remaining' => $remaining]) }}** + +{{ __('tenant::tenant.emails.usage_alert.warning.action_text') }} + + +{{ __('tenant::tenant.emails.usage_alert.view_usage') }} + + +@endif + +{{ __('tenant::tenant.emails.usage_alert.help_text') }} + +Thanks,
+{{ $appName }} +
diff --git a/src/View/Blade/web/account/cancel-deletion.blade.php b/src/View/Blade/web/account/cancel-deletion.blade.php new file mode 100644 index 0000000..49b97a2 --- /dev/null +++ b/src/View/Blade/web/account/cancel-deletion.blade.php @@ -0,0 +1,28 @@ +
+ @if($status === 'success') +
+ + + +
+

{{ __('tenant::tenant.deletion.cancelled.title') }}

+

{{ __('tenant::tenant.deletion.cancelled.message') }}

+ + {{ __('tenant::tenant.deletion.cancelled.go_to_profile') }} + + @elseif($status === 'invalid') +
+ + + +
+

{{ __('tenant::tenant.deletion.cancel_invalid.title') }}

+

{{ __('tenant::tenant.deletion.cancel_invalid.message') }}

+ + {{ __('tenant::tenant.deletion.return_home') }} + + @else +
+

{{ __('tenant::tenant.deletion.processing') }}

+ @endif +
diff --git a/src/View/Blade/web/account/confirm-deletion.blade.php b/src/View/Blade/web/account/confirm-deletion.blade.php new file mode 100644 index 0000000..25ec3d8 --- /dev/null +++ b/src/View/Blade/web/account/confirm-deletion.blade.php @@ -0,0 +1,220 @@ +
+ {{-- Invalid/Expired Token --}} + @if($step === 'invalid') +
+
+ + + +
+

{{ __('tenant::tenant.deletion.invalid.title') }}

+

{{ __('tenant::tenant.deletion.invalid.message') }}

+ + {{ __('tenant::tenant.deletion.return_home') }} + +
+ @endif + + {{-- Step 1: Password Verification --}} + @if($step === 'verify') +
+
+ + + +
+

{{ __('tenant::tenant.deletion.verify.title') }}

+

{{ __('tenant::tenant.deletion.verify.description', ['name' => $userName]) }}

+
+ +
+
+ + + @if($error) +

{{ $error }}

+ @endif +
+ + +
+ +

+ {{ __('tenant::tenant.deletion.verify.changed_mind') }} {{ __('tenant::tenant.deletion.verify.cancel_link') }} +

+ @endif + + {{-- Step 2: Final Confirmation --}} + @if($step === 'confirm') +
+
+ + + +
+

{{ __('tenant::tenant.deletion.confirm.title') }}

+

{!! __('tenant::tenant.deletion.confirm.warning') !!}

+
+ +
+

{{ __('tenant::tenant.deletion.confirm.will_delete') }}

+
    +
  • + + {{ __('tenant::tenant.deletion.confirm.items.profile') }} +
  • +
  • + + {{ __('tenant::tenant.deletion.confirm.items.workspaces') }} +
  • +
  • + + {{ __('tenant::tenant.deletion.confirm.items.content') }} +
  • +
  • + + {{ __('tenant::tenant.deletion.confirm.items.social') }} +
  • +
+
+ +
+ + {{ __('tenant::tenant.deletion.confirm.cancel') }} + + +
+ @endif + + {{-- Step 3: Deleting Animation --}} + @if($step === 'deleting') +
+
+ + + + +
+ +
+
+ +

{{ __('tenant::tenant.deletion.deleting.title') }}

+

+
+ @endif + + {{-- Step 4: Goodbye with Typewriter Effect --}} + @if($step === 'goodbye') +
+
+ + _ +
+ +
+

{{ __('tenant::tenant.deletion.goodbye.deleted') }}

+

{{ __('tenant::tenant.deletion.goodbye.thanks') }}

+ + + + + + {{ __('tenant::tenant.deletion.return_home') }} + +
+
+ @endif +
diff --git a/src/View/Blade/web/workspace/home.blade.php b/src/View/Blade/web/workspace/home.blade.php new file mode 100644 index 0000000..1186197 --- /dev/null +++ b/src/View/Blade/web/workspace/home.blade.php @@ -0,0 +1,156 @@ +@php + $appName = config('core.app.name', __('core::core.brand.name')); +@endphp + +
+ +
+
+
+
+ + + {{ $workspace['name'] }} + +
+

+ {{ $workspace['description'] ?? __('tenant::tenant.workspace.welcome') }} +

+

+ {{ __('tenant::tenant.workspace.powered_by', ['name' => $appName]) }} +

+ +
+
+
+ + +
+
+ @if($loading) +
+
+
+ @else + + @if(!empty($content['posts'])) +
+

{{ __('tenant::tenant.workspace.latest_posts') }}

+
+ @foreach($content['posts'] as $post) + + @endforeach +
+
+ @endif + + + @if(!empty($content['pages'])) +
+

{{ __('tenant::tenant.workspace.pages') }}

+ +
+ @endif + + @if(empty($content['posts']) && empty($content['pages'])) +
+ Vi with empty folder +

{{ __('tenant::tenant.workspace.no_content.title') }}

+

{{ __('tenant::tenant.workspace.no_content.message') }}

+ @auth + + + {{ __('tenant::tenant.workspace.create_content') }} + + @endauth +
+ @endif + @endif +
+
+ + +
+
+
+

{{ __('tenant::tenant.workspace.part_of_toolkit', ['name' => $appName]) }}

+

{{ __('tenant::tenant.workspace.toolkit_description') }}

+
+
+ @php + $services = [ + ['name' => 'BioHost', 'icon' => 'link', 'color' => 'blue', 'slug' => 'link'], + ['name' => 'SocialHost', 'icon' => 'share-nodes', 'color' => 'green', 'slug' => 'social'], + ['name' => 'Analytics', 'icon' => 'chart-line', 'color' => 'yellow', 'slug' => 'analytics'], + ['name' => 'TrustHost', 'icon' => 'shield-check', 'color' => 'orange', 'slug' => 'trust'], + ['name' => 'NotifyHost', 'icon' => 'bell', 'color' => 'red', 'slug' => 'notify'], + ['name' => 'Hestia', 'icon' => 'globe', 'color' => 'violet', 'slug' => 'main'], + ]; + @endphp + @foreach($services as $service) + +
+ +
+ {{ $service['name'] }} +
+ @endforeach +
+
+
+
diff --git a/src/View/Modal/Admin/EntitlementWebhookManager.php b/src/View/Modal/Admin/EntitlementWebhookManager.php new file mode 100644 index 0000000..7e3ea60 --- /dev/null +++ b/src/View/Modal/Admin/EntitlementWebhookManager.php @@ -0,0 +1,356 @@ + ['except' => ''], + 'workspaceId' => ['except' => null], + 'statusFilter' => ['except' => ''], + ]; + + protected array $rules = [ + 'name' => 'required|string|max:255', + 'url' => 'required|url|max:2048', + 'events' => 'required|array|min:1', + 'events.*' => 'string', + 'isActive' => 'boolean', + 'maxAttempts' => 'required|integer|min:1|max:10', + ]; + + public function mount(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades tier required for webhook administration.'); + } + } + + public function updatingSearch(): void + { + $this->resetPage(); + } + + public function updatingWorkspaceId(): void + { + $this->resetPage(); + } + + #[Computed] + public function webhooks() + { + return EntitlementWebhook::query() + ->with('workspace') + ->withCount('deliveries') + ->when($this->workspaceId, fn ($q) => $q->where('workspace_id', $this->workspaceId)) + ->when($this->search, function ($query) { + $query->where(function ($q) { + $q->where('name', 'like', "%{$this->search}%") + ->orWhere('url', 'like', "%{$this->search}%"); + }); + }) + ->when($this->statusFilter === 'active', fn ($q) => $q->active()) + ->when($this->statusFilter === 'inactive', fn ($q) => $q->where('is_active', false)) + ->when($this->statusFilter === 'circuit_broken', fn ($q) => $q->where('failure_count', '>=', EntitlementWebhook::MAX_FAILURES)) + ->latest() + ->paginate(25); + } + + #[Computed] + public function workspaces() + { + return Workspace::query() + ->select('id', 'name', 'slug') + ->orderBy('name') + ->get(); + } + + #[Computed] + public function availableEvents(): array + { + return app(EntitlementWebhookService::class)->getAvailableEvents(); + } + + #[Computed] + public function recentDeliveries() + { + if (! $this->viewingWebhookId) { + return collect(); + } + + return EntitlementWebhookDelivery::query() + ->where('webhook_id', $this->viewingWebhookId) + ->latest('created_at') + ->limit(50) + ->get(); + } + + // ------------------------------------------------------------------------- + // Create/Edit Methods + // ------------------------------------------------------------------------- + + public function create(): void + { + $this->reset(['editingId', 'name', 'url', 'events', 'maxAttempts']); + $this->isActive = true; + $this->maxAttempts = 3; + $this->showFormModal = true; + } + + public function edit(int $id): void + { + $webhook = EntitlementWebhook::findOrFail($id); + + $this->editingId = $webhook->id; + $this->name = $webhook->name; + $this->url = $webhook->url; + $this->events = $webhook->events; + $this->isActive = $webhook->is_active; + $this->maxAttempts = $webhook->max_attempts; + $this->workspaceId = $webhook->workspace_id; + $this->showFormModal = true; + } + + public function save(): void + { + $this->validate(); + + // Filter events to only valid ones + $validEvents = array_intersect($this->events, EntitlementWebhook::EVENTS); + + if (empty($validEvents)) { + $this->addError('events', 'At least one valid event must be selected.'); + + return; + } + + if ($this->editingId) { + $webhook = EntitlementWebhook::findOrFail($this->editingId); + $webhook->update([ + 'name' => $this->name, + 'url' => $this->url, + 'events' => $validEvents, + 'is_active' => $this->isActive, + 'max_attempts' => $this->maxAttempts, + ]); + + $this->setMessage('Webhook updated successfully.'); + } else { + if (! $this->workspaceId) { + $this->addError('workspaceId', 'Please select a workspace.'); + + return; + } + + $workspace = Workspace::findOrFail($this->workspaceId); + $webhook = app(EntitlementWebhookService::class)->register( + workspace: $workspace, + name: $this->name, + url: $this->url, + events: $validEvents + ); + + $webhook->update([ + 'is_active' => $this->isActive, + 'max_attempts' => $this->maxAttempts, + ]); + + // Show the secret to the user + $this->displaySecret = $webhook->secret; + $this->showSecretModal = true; + + $this->setMessage('Webhook created successfully. Please save the secret below.'); + } + + $this->showFormModal = false; + $this->reset(['editingId', 'name', 'url', 'events']); + } + + public function closeFormModal(): void + { + $this->showFormModal = false; + $this->reset(['editingId', 'name', 'url', 'events']); + $this->resetValidation(); + } + + // ------------------------------------------------------------------------- + // Action Methods + // ------------------------------------------------------------------------- + + public function toggleActive(int $id): void + { + $webhook = EntitlementWebhook::findOrFail($id); + $webhook->update(['is_active' => ! $webhook->is_active]); + + $this->setMessage($webhook->is_active ? 'Webhook enabled.' : 'Webhook disabled.'); + } + + public function delete(int $id): void + { + $webhook = EntitlementWebhook::findOrFail($id); + $webhook->delete(); + + $this->setMessage('Webhook deleted.'); + } + + public function testWebhook(int $id): void + { + $webhook = EntitlementWebhook::findOrFail($id); + $delivery = app(EntitlementWebhookService::class)->testWebhook($webhook); + + if ($delivery->isSucceeded()) { + $this->setMessage('Test webhook sent successfully.'); + } else { + $this->setMessage('Test webhook failed. Check delivery history for details.', 'error'); + } + } + + public function regenerateSecret(int $id): void + { + $webhook = EntitlementWebhook::findOrFail($id); + $secret = $webhook->regenerateSecret(); + + $this->displaySecret = $secret; + $this->showSecretModal = true; + } + + public function resetCircuitBreaker(int $id): void + { + $webhook = EntitlementWebhook::findOrFail($id); + app(EntitlementWebhookService::class)->resetCircuitBreaker($webhook); + + $this->setMessage('Webhook re-enabled and failure count reset.'); + } + + // ------------------------------------------------------------------------- + // Deliveries Modal + // ------------------------------------------------------------------------- + + public function viewDeliveries(int $id): void + { + $this->viewingWebhookId = $id; + $this->showDeliveriesModal = true; + } + + public function closeDeliveriesModal(): void + { + $this->showDeliveriesModal = false; + $this->viewingWebhookId = null; + } + + public function retryDelivery(int $deliveryId): void + { + $delivery = EntitlementWebhookDelivery::findOrFail($deliveryId); + + try { + $result = app(EntitlementWebhookService::class)->retryDelivery($delivery); + + if ($result->isSucceeded()) { + $this->setMessage('Delivery retried successfully.'); + } else { + $this->setMessage('Retry failed. Check delivery details.', 'error'); + } + } catch (\Exception $e) { + $this->setMessage($e->getMessage(), 'error'); + } + } + + // ------------------------------------------------------------------------- + // Secret Modal + // ------------------------------------------------------------------------- + + public function closeSecretModal(): void + { + $this->showSecretModal = false; + $this->displaySecret = null; + } + + // ------------------------------------------------------------------------- + // Helper Methods + // ------------------------------------------------------------------------- + + protected function setMessage(string $message, string $type = 'success'): void + { + $this->message = $message; + $this->messageType = $type; + } + + public function clearMessage(): void + { + $this->message = ''; + } + + #[Computed] + public function stats(): array + { + $query = EntitlementWebhook::query(); + + if ($this->workspaceId) { + $query->where('workspace_id', $this->workspaceId); + } + + return [ + 'total' => (clone $query)->count(), + 'active' => (clone $query)->where('is_active', true)->count(), + 'circuit_broken' => (clone $query)->where('failure_count', '>=', EntitlementWebhook::MAX_FAILURES)->count(), + ]; + } + + public function render(): View + { + return view('tenant::admin.entitlement-webhook-manager') + ->layout('hub::admin.layouts.app', ['title' => 'Entitlement Webhooks']); + } +} diff --git a/src/View/Modal/Admin/WorkspaceDetails.php b/src/View/Modal/Admin/WorkspaceDetails.php new file mode 100644 index 0000000..8f0c0c0 --- /dev/null +++ b/src/View/Modal/Admin/WorkspaceDetails.php @@ -0,0 +1,584 @@ +user()?->isHades()) { + abort(403, 'Hades tier required for workspace administration.'); + } + + $this->workspace = Workspace::findOrFail($id); + } + + #[Computed] + public function teamMembers() + { + return $this->workspace->users() + ->orderByRaw("FIELD(user_workspace.role, 'owner', 'admin', 'member')") + ->orderBy('name') + ->get(); + } + + #[Computed] + public function availableUsers() + { + $existingIds = $this->workspace->users()->pluck('users.id')->toArray(); + + return User::whereNotIn('id', $existingIds) + ->orderBy('name') + ->get(['id', 'name', 'email']); + } + + #[Computed] + public function resourceCounts(): array + { + $counts = []; + $schema = \Illuminate\Support\Facades\Schema::getFacadeRoot(); + + $resources = [ + ['relation' => 'bioPages', 'label' => 'Bio Pages', 'icon' => 'link', 'color' => 'blue', 'model' => \Core\Mod\Web\Models\Page::class], + ['relation' => 'bioProjects', 'label' => 'Bio Projects', 'icon' => 'folder', 'color' => 'indigo', 'model' => \Core\Mod\Web\Models\Project::class], + ['relation' => 'socialAccounts', 'label' => 'Social Accounts', 'icon' => 'share-nodes', 'color' => 'purple', 'model' => \Core\Mod\Social\Models\Account::class], + ['relation' => 'socialPosts', 'label' => 'Social Posts', 'icon' => 'paper-plane', 'color' => 'pink', 'model' => \Core\Mod\Social\Models\Post::class], + ['relation' => 'analyticsSites', 'label' => 'Analytics Sites', 'icon' => 'chart-line', 'color' => 'cyan', 'model' => \Core\Mod\Analytics\Models\Website::class], + ['relation' => 'trustWidgets', 'label' => 'Trust Campaigns', 'icon' => 'shield-check', 'color' => 'emerald', 'model' => \Core\Mod\Trust\Models\Campaign::class], + ['relation' => 'notificationSites', 'label' => 'Notification Sites', 'icon' => 'bell', 'color' => 'amber', 'model' => \Core\Mod\Notify\Models\PushWebsite::class], + ['relation' => 'contentItems', 'label' => 'Content Items', 'icon' => 'file-lines', 'color' => 'slate', 'model' => \Core\Mod\Content\Models\ContentItem::class], + ['relation' => 'apiKeys', 'label' => 'API Keys', 'icon' => 'key', 'color' => 'rose', 'model' => \Core\Mod\Api\Models\ApiKey::class], + ]; + + foreach ($resources as $resource) { + if (class_exists($resource['model'])) { + try { + $counts[] = [ + 'label' => $resource['label'], + 'icon' => $resource['icon'], + 'color' => $resource['color'], + 'count' => $this->workspace->{$resource['relation']}()->count(), + ]; + } catch (\Exception $e) { + // Skip if relation fails + } + } + } + + return $counts; + } + + #[Computed] + public function recentActivity() + { + $activities = collect(); + + // Entitlement logs + if (class_exists(\Core\Mod\Tenant\Models\EntitlementLog::class)) { + try { + $logs = $this->workspace->entitlementLogs() + ->with('user', 'feature') + ->latest() + ->take(10) + ->get() + ->map(fn ($log) => [ + 'type' => 'entitlement', + 'icon' => $log->action === 'allowed' ? 'check-circle' : 'times-circle', + 'color' => $log->action === 'allowed' ? 'green' : 'red', + 'message' => ($log->user?->name ?? 'System').' '.($log->action === 'allowed' ? 'used' : 'was denied').' '.$log->feature?->name, + 'detail' => $log->reason, + 'created_at' => $log->created_at, + ]); + $activities = $activities->merge($logs); + } catch (\Exception $e) { + // Skip + } + } + + // Usage records + if (class_exists(\Core\Mod\Tenant\Models\UsageRecord::class)) { + try { + $usage = $this->workspace->usageRecords() + ->with('user', 'feature') + ->latest() + ->take(10) + ->get() + ->map(fn ($record) => [ + 'type' => 'usage', + 'icon' => 'chart-bar', + 'color' => 'blue', + 'message' => ($record->user?->name ?? 'System').' used '.$record->quantity.' '.$record->feature?->name, + 'detail' => null, + 'created_at' => $record->created_at, + ]); + $activities = $activities->merge($usage); + } catch (\Exception $e) { + // Skip + } + } + + return $activities->sortByDesc('created_at')->take(15)->values(); + } + + #[Computed] + public function activePackages() + { + return $this->workspace->workspacePackages() + ->with('package') + ->active() + ->get(); + } + + #[Computed] + public function subscriptionInfo(): ?array + { + $subscription = $this->workspace->activeSubscription(); + + if (! $subscription) { + return null; + } + + return [ + 'plan' => $subscription->plan_name ?? 'Unknown', + 'status' => $subscription->status, + 'current_period_end' => $subscription->current_period_end?->format('d M Y'), + 'amount' => $subscription->amount ? number_format($subscription->amount / 100, 2) : null, + 'currency' => $subscription->currency ?? 'GBP', + ]; + } + + public function setTab(string $tab): void + { + $this->activeTab = $tab; + } + + // Team management + + public function openAddMember(): void + { + $this->newMemberId = null; + $this->newMemberRole = 'member'; + $this->showAddMemberModal = true; + } + + public function closeAddMember(): void + { + $this->showAddMemberModal = false; + $this->reset(['newMemberId', 'newMemberRole']); + } + + public function addMember(): void + { + if (! $this->newMemberId) { + $this->actionMessage = 'Please select a user.'; + $this->actionType = 'error'; + + return; + } + + $user = User::findOrFail($this->newMemberId); + + $this->workspace->users()->attach($user->id, ['role' => $this->newMemberRole]); + + $this->closeAddMember(); + $this->actionMessage = "{$user->name} added to workspace as {$this->newMemberRole}."; + $this->actionType = 'success'; + unset($this->teamMembers, $this->availableUsers); + } + + public function openEditMember(int $userId): void + { + $member = $this->workspace->users()->where('user_id', $userId)->first(); + if (! $member) { + return; + } + + $this->editingMemberId = $userId; + $this->editingMemberRole = $member->pivot->role ?? 'member'; + $this->showEditMemberModal = true; + } + + public function closeEditMember(): void + { + $this->showEditMemberModal = false; + $this->reset(['editingMemberId', 'editingMemberRole']); + } + + public function updateMemberRole(): void + { + if (! $this->editingMemberId) { + return; + } + + $this->workspace->users()->updateExistingPivot($this->editingMemberId, [ + 'role' => $this->editingMemberRole, + ]); + + $user = User::find($this->editingMemberId); + $this->closeEditMember(); + $this->actionMessage = "{$user?->name}'s role updated to {$this->editingMemberRole}."; + $this->actionType = 'success'; + unset($this->teamMembers); + } + + public function removeMember(int $userId): void + { + $member = $this->workspace->users()->where('user_id', $userId)->first(); + + if ($member?->pivot?->role === 'owner') { + $this->actionMessage = 'Cannot remove the workspace owner. Transfer ownership first.'; + $this->actionType = 'error'; + + return; + } + + $this->workspace->users()->detach($userId); + + $this->actionMessage = "{$member?->name} removed from workspace."; + $this->actionType = 'success'; + unset($this->teamMembers, $this->availableUsers); + } + + // Domain management + + public function openEditDomain(): void + { + $this->editingDomain = $this->workspace->domain ?? ''; + $this->showEditDomainModal = true; + } + + public function closeEditDomain(): void + { + $this->showEditDomainModal = false; + $this->reset(['editingDomain']); + } + + public function saveDomain(): void + { + $domain = trim($this->editingDomain); + + // Remove protocol if present + $domain = preg_replace('#^https?://#', '', $domain); + $domain = rtrim($domain, '/'); + + $this->workspace->update(['domain' => $domain ?: null]); + $this->workspace->refresh(); + + $this->closeEditDomain(); + $this->actionMessage = $domain ? "Domain updated to {$domain}." : 'Domain removed.'; + $this->actionType = 'success'; + } + + // Entitlements tab + + #[Computed] + public function allPackages() + { + return \Core\Mod\Tenant\Models\Package::active() + ->ordered() + ->get(); + } + + #[Computed] + public function allFeatures() + { + return \Core\Mod\Tenant\Models\Feature::active() + ->orderBy('category') + ->orderBy('sort_order') + ->get(); + } + + #[Computed] + public function activeBoosts() + { + return $this->workspace->boosts() + ->usable() + ->orderBy('feature_code') + ->get(); + } + + #[Computed] + public function entitlementStats(): array + { + $resolved = $this->resolvedEntitlements; + $total = 0; + $allowed = 0; + $denied = 0; + $nearLimit = 0; + + foreach ($resolved as $category => $features) { + foreach ($features as $feature) { + $total++; + if ($feature['allowed']) { + $allowed++; + if ($feature['near_limit']) { + $nearLimit++; + } + } else { + $denied++; + } + } + } + + return [ + 'total' => $total, + 'allowed' => $allowed, + 'denied' => $denied, + 'near_limit' => $nearLimit, + 'packages' => $this->workspacePackages->count(), + 'boosts' => $this->activeBoosts->count(), + ]; + } + + #[Computed] + public function workspacePackages() + { + return $this->workspace->workspacePackages() + ->with(['package.features']) + ->get(); + } + + #[Computed] + public function usageSummary() + { + try { + return $this->workspace->getUsageSummary(); + } catch (\Exception $e) { + return collect(); + } + } + + #[Computed] + public function resolvedEntitlements() + { + try { + return app(\Core\Mod\Tenant\Services\EntitlementService::class) + ->getUsageSummary($this->workspace); + } catch (\Exception $e) { + return collect(); + } + } + + public function openAddPackage(): void + { + $this->selectedPackageId = null; + $this->showAddPackageModal = true; + } + + public function closeAddPackage(): void + { + $this->showAddPackageModal = false; + $this->reset(['selectedPackageId']); + } + + public function addPackage(): void + { + if (! $this->selectedPackageId) { + $this->actionMessage = 'Please select a package.'; + $this->actionType = 'error'; + + return; + } + + $package = \Core\Mod\Tenant\Models\Package::findOrFail($this->selectedPackageId); + + // Check if already assigned + $existing = $this->workspace->workspacePackages() + ->where('package_id', $package->id) + ->where('status', 'active') + ->exists(); + + if ($existing) { + $this->actionMessage = "Package '{$package->name}' is already assigned."; + $this->actionType = 'error'; + + return; + } + + \Core\Mod\Tenant\Models\WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $package->id, + 'status' => 'active', + 'starts_at' => now(), + ]); + + $this->closeAddPackage(); + $this->actionMessage = "Package '{$package->name}' assigned to workspace."; + $this->actionType = 'success'; + unset($this->workspacePackages, $this->activePackages); + } + + public function removePackage(int $workspacePackageId): void + { + $wp = \Core\Mod\Tenant\Models\WorkspacePackage::where('workspace_id', $this->workspace->id) + ->findOrFail($workspacePackageId); + + $packageName = $wp->package?->name ?? 'Package'; + $wp->delete(); + + $this->actionMessage = "Package '{$packageName}' removed from workspace."; + $this->actionType = 'success'; + unset($this->workspacePackages, $this->activePackages); + } + + public function suspendPackage(int $workspacePackageId): void + { + $wp = \Core\Mod\Tenant\Models\WorkspacePackage::where('workspace_id', $this->workspace->id) + ->findOrFail($workspacePackageId); + + $wp->suspend(); + + $this->actionMessage = "Package '{$wp->package?->name}' suspended."; + $this->actionType = 'warning'; + unset($this->workspacePackages, $this->activePackages); + } + + public function reactivatePackage(int $workspacePackageId): void + { + $wp = \Core\Mod\Tenant\Models\WorkspacePackage::where('workspace_id', $this->workspace->id) + ->findOrFail($workspacePackageId); + + $wp->reactivate(); + + $this->actionMessage = "Package '{$wp->package?->name}' reactivated."; + $this->actionType = 'success'; + unset($this->workspacePackages, $this->activePackages); + } + + // Entitlement (Boost) management + + public function openAddEntitlement(): void + { + $this->selectedFeatureCode = null; + $this->entitlementType = 'enable'; + $this->entitlementLimit = null; + $this->entitlementDuration = 'permanent'; + $this->entitlementExpiresAt = null; + $this->showAddEntitlementModal = true; + } + + public function closeAddEntitlement(): void + { + $this->showAddEntitlementModal = false; + $this->reset(['selectedFeatureCode', 'entitlementType', 'entitlementLimit', 'entitlementDuration', 'entitlementExpiresAt']); + } + + public function addEntitlement(): void + { + if (! $this->selectedFeatureCode) { + $this->actionMessage = 'Please select a feature.'; + $this->actionType = 'error'; + + return; + } + + $feature = \Core\Mod\Tenant\Models\Feature::where('code', $this->selectedFeatureCode)->first(); + + if (! $feature) { + $this->actionMessage = 'Feature not found.'; + $this->actionType = 'error'; + + return; + } + + // Map type to boost type constant + $boostType = match ($this->entitlementType) { + 'enable' => \Core\Mod\Tenant\Models\Boost::BOOST_TYPE_ENABLE, + 'add_limit' => \Core\Mod\Tenant\Models\Boost::BOOST_TYPE_ADD_LIMIT, + 'unlimited' => \Core\Mod\Tenant\Models\Boost::BOOST_TYPE_UNLIMITED, + default => \Core\Mod\Tenant\Models\Boost::BOOST_TYPE_ENABLE, + }; + + $durationType = $this->entitlementDuration === 'permanent' + ? \Core\Mod\Tenant\Models\Boost::DURATION_PERMANENT + : \Core\Mod\Tenant\Models\Boost::DURATION_DURATION; + + \Core\Mod\Tenant\Models\Boost::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => $this->selectedFeatureCode, + 'boost_type' => $boostType, + 'duration_type' => $durationType, + 'limit_value' => $this->entitlementType === 'add_limit' ? $this->entitlementLimit : null, + 'consumed_quantity' => 0, + 'status' => \Core\Mod\Tenant\Models\Boost::STATUS_ACTIVE, + 'starts_at' => now(), + 'expires_at' => $this->entitlementExpiresAt ? \Carbon\Carbon::parse($this->entitlementExpiresAt) : null, + 'metadata' => ['granted_by' => auth()->id(), 'granted_at' => now()->toDateTimeString()], + ]); + + $this->closeAddEntitlement(); + $this->actionMessage = "Entitlement '{$feature->name}' granted to workspace."; + $this->actionType = 'success'; + unset($this->activeBoosts, $this->resolvedEntitlements, $this->entitlementStats); + } + + public function removeBoost(int $boostId): void + { + $boost = \Core\Mod\Tenant\Models\Boost::where('workspace_id', $this->workspace->id) + ->findOrFail($boostId); + + $featureCode = $boost->feature_code; + $boost->cancel(); + + $this->actionMessage = "Entitlement '{$featureCode}' removed."; + $this->actionType = 'success'; + unset($this->activeBoosts, $this->resolvedEntitlements, $this->entitlementStats); + } + + public function render() + { + return view('tenant::admin.workspace-details') + ->layout('hub::admin.layouts.app', ['title' => 'Workspace: '.$this->workspace->name]); + } +} diff --git a/src/View/Modal/Admin/WorkspaceManager.php b/src/View/Modal/Admin/WorkspaceManager.php new file mode 100644 index 0000000..f01fa04 --- /dev/null +++ b/src/View/Modal/Admin/WorkspaceManager.php @@ -0,0 +1,666 @@ + ['except' => ''], + ]; + + protected array $rules = [ + 'name' => 'required|string|max:255', + 'slug' => 'required|string|max:255|alpha_dash', + 'isActive' => 'boolean', + ]; + + public function mount(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades tier required for workspace administration.'); + } + } + + public function updatingSearch(): void + { + $this->resetPage(); + } + + #[Computed] + public function workspaces() + { + return Workspace::query() + ->withCount($this->getAvailableRelations()) + ->when($this->search, function ($query) { + $query->where(function ($q) { + $q->where('name', 'like', "%{$this->search}%") + ->orWhere('slug', 'like', "%{$this->search}%"); + }); + }) + ->orderBy('name') + ->paginate(20); + } + + /** + * Get relations that are available for counting. + * Filters out relations whose models don't exist yet or have incompatible schemas. + */ + protected function getAvailableRelations(): array + { + $relations = []; + + // Check each relation's model exists and has workspace_id column + $checks = [ + 'bioPages' => ['model' => \Core\Mod\Web\Models\Page::class, 'table' => 'pages'], + 'bioProjects' => ['model' => \Core\Mod\Web\Models\Project::class, 'table' => 'page_projects'], + 'socialAccounts' => ['model' => \Core\Mod\Social\Models\Account::class, 'table' => 'social_accounts'], + 'analyticsSites' => ['model' => \Core\Mod\Analytics\Models\Website::class, 'table' => 'analytics_websites'], + 'trustWidgets' => ['model' => \Core\Mod\Trust\Models\Campaign::class, 'table' => 'trust_campaigns'], + 'notificationSites' => ['model' => \Core\Mod\Notify\Models\PushWebsite::class, 'table' => 'push_websites'], + ]; + + $schema = \Illuminate\Support\Facades\Schema::getFacadeRoot(); + + foreach ($checks as $relation => $info) { + if (class_exists($info['model'])) { + // Verify the table has workspace_id column + try { + if ($schema->hasColumn($info['table'], 'workspace_id')) { + $relations[] = $relation; + } + } catch (\Exception $e) { + // Table might not exist yet, skip + } + } + } + + return $relations; + } + + #[Computed] + public function allWorkspaces() + { + return Workspace::orderBy('name')->get(['id', 'name', 'slug']); + } + + #[Computed] + public function resourceTypes(): array + { + $types = []; + $schema = \Illuminate\Support\Facades\Schema::getFacadeRoot(); + + // Only include resource types for models that exist and have valid relations + $checks = [ + 'bio_pages' => ['model' => \Core\Mod\Web\Models\Page::class, 'table' => 'pages', 'label' => 'Bio Pages', 'relation' => 'bioPages', 'icon' => 'link'], + 'bio_projects' => ['model' => \Core\Mod\Web\Models\Project::class, 'table' => 'page_projects', 'label' => 'Bio Projects', 'relation' => 'bioProjects', 'icon' => 'folder'], + 'social_accounts' => ['model' => \Core\Mod\Social\Models\Account::class, 'table' => 'social_accounts', 'label' => 'Social Accounts', 'relation' => 'socialAccounts', 'icon' => 'share-nodes'], + 'analytics_sites' => ['model' => \Core\Mod\Analytics\Models\Website::class, 'table' => 'analytics_websites', 'label' => 'Analytics Sites', 'relation' => 'analyticsSites', 'icon' => 'chart-line'], + 'trust_widgets' => ['model' => \Core\Mod\Trust\Models\Campaign::class, 'table' => 'trust_campaigns', 'label' => 'Trust Campaigns', 'relation' => 'trustWidgets', 'icon' => 'shield-check'], + 'notification_sites' => ['model' => \Core\Mod\Notify\Models\PushWebsite::class, 'table' => 'push_websites', 'label' => 'Notification Sites', 'relation' => 'notificationSites', 'icon' => 'bell'], + ]; + + foreach ($checks as $key => $info) { + if (class_exists($info['model'])) { + try { + if ($schema->hasColumn($info['table'], 'workspace_id')) { + $types[$key] = [ + 'label' => $info['label'], + 'relation' => $info['relation'], + 'icon' => $info['icon'], + ]; + } + } catch (\Exception $e) { + // Table might not exist yet, skip + } + } + } + + return $types; + } + + public function openEdit(int $id): void + { + $workspace = Workspace::findOrFail($id); + $this->editingId = $id; + $this->name = $workspace->name; + $this->slug = $workspace->slug; + $this->isActive = $workspace->is_active; + } + + public function closeEdit(): void + { + $this->editingId = null; + $this->reset(['name', 'slug', 'isActive']); + $this->resetErrorBag(); + } + + public function save(): void + { + $this->validate(); + + $workspace = Workspace::findOrFail($this->editingId); + + // Check if slug is unique (excluding current workspace) + $slugExists = Workspace::where('slug', $this->slug) + ->where('id', '!=', $this->editingId) + ->exists(); + + if ($slugExists) { + $this->addError('slug', 'This slug is already in use.'); + + return; + } + + $workspace->update([ + 'name' => $this->name, + 'slug' => $this->slug, + 'is_active' => $this->isActive, + ]); + + $this->closeEdit(); + $this->actionMessage = "Workspace '{$workspace->name}' updated successfully."; + $this->actionType = 'success'; + unset($this->workspaces); + } + + public function delete(int $id): void + { + $workspace = Workspace::withCount($this->getAvailableRelations())->findOrFail($id); + + // Check for resources (safely get counts that might not exist) + $totalResources = ($workspace->bio_pages_count ?? 0) + + ($workspace->bio_projects_count ?? 0) + + ($workspace->social_accounts_count ?? 0) + + ($workspace->analytics_sites_count ?? 0) + + ($workspace->trust_widgets_count ?? 0) + + ($workspace->notification_sites_count ?? 0) + + ($workspace->orders_count ?? 0); + + if ($totalResources > 0) { + $this->actionMessage = "Cannot delete workspace '{$workspace->name}'. It has {$totalResources} resources. Transfer or delete them first."; + $this->actionType = 'error'; + + return; + } + + // Check for users + if ($workspace->users()->count() > 0) { + $this->actionMessage = "Cannot delete workspace '{$workspace->name}'. It still has users assigned."; + $this->actionType = 'error'; + + return; + } + + $workspaceName = $workspace->name; + $workspace->delete(); + + $this->actionMessage = "Workspace '{$workspaceName}' deleted successfully."; + $this->actionType = 'success'; + unset($this->workspaces); + } + + public function openTransfer(int $workspaceId): void + { + $this->sourceWorkspaceId = $workspaceId; + $this->targetWorkspaceId = null; + $this->selectedResourceTypes = []; + $this->showTransferModal = true; + } + + public function closeTransfer(): void + { + $this->showTransferModal = false; + $this->reset(['sourceWorkspaceId', 'targetWorkspaceId', 'selectedResourceTypes']); + } + + public function executeTransfer(): void + { + if (! $this->sourceWorkspaceId || ! $this->targetWorkspaceId) { + $this->actionMessage = 'Please select both source and target workspaces.'; + $this->actionType = 'error'; + + return; + } + + if ($this->sourceWorkspaceId === $this->targetWorkspaceId) { + $this->actionMessage = 'Source and target workspaces cannot be the same.'; + $this->actionType = 'error'; + + return; + } + + if (empty($this->selectedResourceTypes)) { + $this->actionMessage = 'Please select at least one resource type to transfer.'; + $this->actionType = 'error'; + + return; + } + + $source = Workspace::findOrFail($this->sourceWorkspaceId); + $target = Workspace::findOrFail($this->targetWorkspaceId); + $resourceTypes = $this->resourceTypes; + $transferred = []; + + DB::transaction(function () use ($source, $target, $resourceTypes, &$transferred) { + foreach ($this->selectedResourceTypes as $type) { + if (! isset($resourceTypes[$type])) { + continue; + } + + $relation = $resourceTypes[$type]['relation']; + $count = $source->{$relation}()->count(); + + if ($count > 0) { + $source->{$relation}()->update(['workspace_id' => $target->id]); + $transferred[$resourceTypes[$type]['label']] = $count; + } + } + }); + + $this->closeTransfer(); + + if (empty($transferred)) { + $this->actionMessage = 'No resources were transferred (source had no resources of selected types).'; + $this->actionType = 'warning'; + } else { + $summary = collect($transferred) + ->map(fn ($count, $label) => "{$count} {$label}") + ->join(', '); + $this->actionMessage = "Transferred {$summary} from '{$source->name}' to '{$target->name}'."; + $this->actionType = 'success'; + } + + unset($this->workspaces); + } + + #[Computed] + public function allUsers() + { + return User::orderBy('name')->get(['id', 'name', 'email']); + } + + public function openChangeOwner(int $workspaceId): void + { + $workspace = Workspace::findOrFail($workspaceId); + $this->ownerWorkspaceId = $workspaceId; + $this->newOwnerId = $workspace->owner()?->id; + $this->showOwnerModal = true; + } + + public function closeChangeOwner(): void + { + $this->showOwnerModal = false; + $this->reset(['ownerWorkspaceId', 'newOwnerId']); + } + + public function changeOwner(): void + { + if (! $this->ownerWorkspaceId || ! $this->newOwnerId) { + $this->actionMessage = 'Please select a new owner.'; + $this->actionType = 'error'; + + return; + } + + $workspace = Workspace::findOrFail($this->ownerWorkspaceId); + $newOwner = User::findOrFail($this->newOwnerId); + $oldOwner = $workspace->owner(); + + DB::transaction(function () use ($workspace, $newOwner, $oldOwner) { + // Remove owner role from current owner (if exists) + if ($oldOwner) { + $workspace->users()->updateExistingPivot($oldOwner->id, ['role' => 'member']); + } + + // Check if new owner is already a member + if ($workspace->users()->where('user_id', $newOwner->id)->exists()) { + // Update existing membership to owner + $workspace->users()->updateExistingPivot($newOwner->id, ['role' => 'owner']); + } else { + // Add new owner to workspace + $workspace->users()->attach($newOwner->id, ['role' => 'owner']); + } + }); + + $this->closeChangeOwner(); + $this->actionMessage = "Ownership of '{$workspace->name}' transferred to {$newOwner->name}."; + $this->actionType = 'success'; + unset($this->workspaces); + } + + public function openResources(int $workspaceId, string $type): void + { + $this->resourcesWorkspaceId = $workspaceId; + $this->resourcesType = $type; + $this->selectedResources = []; + $this->resourcesTargetWorkspaceId = null; + $this->showResourcesModal = true; + } + + public function closeResources(): void + { + $this->showResourcesModal = false; + $this->reset(['resourcesWorkspaceId', 'resourcesType', 'selectedResources', 'resourcesTargetWorkspaceId']); + } + + #[Computed] + public function currentResources(): array + { + if (! $this->resourcesWorkspaceId || ! $this->resourcesType) { + return []; + } + + $resourceTypes = $this->resourceTypes; + if (! isset($resourceTypes[$this->resourcesType])) { + return []; + } + + $workspace = Workspace::find($this->resourcesWorkspaceId); + if (! $workspace) { + return []; + } + + $relation = $resourceTypes[$this->resourcesType]['relation']; + + return $workspace->{$relation}() + ->get() + ->map(function ($item) { + return [ + 'id' => $item->id, + 'name' => $item->name ?? $item->title ?? "#{$item->id}", + 'detail' => $item->url ?? $item->domain ?? $item->email ?? $item->slug ?? null, + 'created_at' => $item->created_at?->format('d M Y'), + ]; + }) + ->toArray(); + } + + public function toggleResourceSelection(int $id): void + { + if (in_array($id, $this->selectedResources)) { + $this->selectedResources = array_values(array_diff($this->selectedResources, [$id])); + } else { + $this->selectedResources[] = $id; + } + } + + public function selectAllResources(): void + { + $this->selectedResources = collect($this->currentResources)->pluck('id')->toArray(); + } + + public function deselectAllResources(): void + { + $this->selectedResources = []; + } + + public function transferSelectedResources(): void + { + if (empty($this->selectedResources)) { + $this->actionMessage = 'Please select at least one resource to transfer.'; + $this->actionType = 'error'; + + return; + } + + if (! $this->resourcesTargetWorkspaceId) { + $this->actionMessage = 'Please select a target workspace.'; + $this->actionType = 'error'; + + return; + } + + if ($this->resourcesWorkspaceId === $this->resourcesTargetWorkspaceId) { + $this->actionMessage = 'Source and target workspaces cannot be the same.'; + $this->actionType = 'error'; + + return; + } + + $resourceTypes = $this->resourceTypes; + if (! isset($resourceTypes[$this->resourcesType])) { + $this->actionMessage = 'Invalid resource type.'; + $this->actionType = 'error'; + + return; + } + + $workspace = Workspace::findOrFail($this->resourcesWorkspaceId); + $target = Workspace::findOrFail($this->resourcesTargetWorkspaceId); + $relation = $resourceTypes[$this->resourcesType]['relation']; + $label = $resourceTypes[$this->resourcesType]['label']; + + $count = $workspace->{$relation}() + ->whereIn('id', $this->selectedResources) + ->update(['workspace_id' => $target->id]); + + $this->closeResources(); + $this->actionMessage = "Transferred {$count} {$label} from '{$workspace->name}' to '{$target->name}'."; + $this->actionType = 'success'; + unset($this->workspaces); + } + + public function openProvision(int $workspaceId, string $type): void + { + $this->provisionWorkspaceId = $workspaceId; + $this->provisionType = $type; + $this->provisionName = ''; + $this->provisionUrl = ''; + $this->showProvisionModal = true; + } + + public function closeProvision(): void + { + $this->showProvisionModal = false; + $this->reset(['provisionWorkspaceId', 'provisionType', 'provisionName', 'provisionUrl', 'provisionSlug']); + } + + #[Computed] + public function provisionConfig(): array + { + return [ + 'bio_pages' => [ + 'label' => 'Bio Page', + 'icon' => 'link', + 'color' => 'blue', + 'fields' => ['name', 'slug'], + 'model' => \Core\Mod\Web\Models\Page::class, + 'defaults' => ['type' => 'page', 'is_enabled' => true], + ], + 'social_accounts' => [ + 'label' => 'Social Account', + 'icon' => 'share-nodes', + 'color' => 'purple', + 'fields' => ['name'], + 'model' => \Core\Mod\Social\Models\Account::class, + 'defaults' => ['provider' => 'manual', 'status' => 'active'], + ], + 'analytics_sites' => [ + 'label' => 'Analytics Site', + 'icon' => 'chart-line', + 'color' => 'cyan', + 'fields' => ['name', 'url'], + 'model' => \Core\Mod\Analytics\Models\Website::class, + 'defaults' => ['tracking_enabled' => true, 'is_enabled' => true], + ], + 'trust_widgets' => [ + 'label' => 'Trust Campaign', + 'icon' => 'shield-check', + 'color' => 'emerald', + 'fields' => ['name'], + 'model' => \Core\Mod\Trust\Models\Campaign::class, + 'defaults' => ['status' => 'draft'], + ], + 'notification_sites' => [ + 'label' => 'Notification Site', + 'icon' => 'bell', + 'color' => 'amber', + 'fields' => ['name', 'url'], + 'model' => \Core\Mod\Notify\Models\PushWebsite::class, + 'defaults' => ['status' => 'active'], + ], + ]; + } + + public function provisionResource(): void + { + $config = $this->provisionConfig[$this->provisionType] ?? null; + + if (! $config || ! class_exists($config['model'])) { + $this->actionMessage = 'Invalid resource type or model not available.'; + $this->actionType = 'error'; + + return; + } + + if (empty($this->provisionName)) { + $this->actionMessage = 'Please enter a name.'; + $this->actionType = 'error'; + + return; + } + + if (in_array('url', $config['fields']) && empty($this->provisionUrl)) { + $this->actionMessage = 'Please enter a URL.'; + $this->actionType = 'error'; + + return; + } + + if (in_array('slug', $config['fields']) && empty($this->provisionSlug)) { + $this->actionMessage = 'Please enter a slug.'; + $this->actionType = 'error'; + + return; + } + + $workspace = Workspace::findOrFail($this->provisionWorkspaceId); + + $data = array_merge($config['defaults'], [ + 'workspace_id' => $workspace->id, + ]); + + // Handle name - for bio pages it goes in settings + if ($this->provisionType === 'bio_pages') { + $data['settings'] = ['page_title' => $this->provisionName]; + } else { + $data['name'] = $this->provisionName; + } + + // Add slug for bio pages + if (in_array('slug', $config['fields']) && $this->provisionSlug) { + $data['url'] = \Illuminate\Support\Str::slug($this->provisionSlug); + } + + // Add URL-related fields if applicable + if (in_array('url', $config['fields']) && $this->provisionUrl) { + $url = $this->provisionUrl; + if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) { + $url = 'https://'.$url; + } + $parsed = parse_url($url); + $data['url'] = $url; + $data['host'] = $parsed['host'] ?? null; + $data['scheme'] = $parsed['scheme'] ?? 'https'; + } + + // Add user_id if the model expects it + if (auth()->check()) { + $data['user_id'] = auth()->id(); + } + + try { + $config['model']::create($data); + + $this->closeProvision(); + $this->actionMessage = "{$config['label']} '{$this->provisionName}' created in '{$workspace->name}'."; + $this->actionType = 'success'; + unset($this->workspaces); + } catch (\Exception $e) { + $this->actionMessage = "Failed to create resource: {$e->getMessage()}"; + $this->actionType = 'error'; + } + } + + public function getStats(): array + { + return [ + 'total' => Workspace::count(), + 'active' => Workspace::where('is_active', true)->count(), + 'inactive' => Workspace::where('is_active', false)->count(), + ]; + } + + public function render() + { + return view('tenant::admin.workspace-manager', [ + 'stats' => $this->getStats(), + ])->layout('hub::admin.layouts.app', ['title' => 'Workspace Manager']); + } +} diff --git a/src/View/Modal/Web/CancelDeletion.php b/src/View/Modal/Web/CancelDeletion.php new file mode 100644 index 0000000..5b838fc --- /dev/null +++ b/src/View/Modal/Web/CancelDeletion.php @@ -0,0 +1,36 @@ +token = $token; + $deletionRequest = AccountDeletionRequest::findValidByToken($token); + + if (! $deletionRequest) { + $this->status = 'invalid'; + + return; + } + + // Cancel the deletion request + $deletionRequest->cancel(); + $this->status = 'success'; + } + + public function render() + { + return view('tenant::web.account.cancel-deletion'); + } +} diff --git a/src/View/Modal/Web/ConfirmDeletion.php b/src/View/Modal/Web/ConfirmDeletion.php new file mode 100644 index 0000000..6cc7512 --- /dev/null +++ b/src/View/Modal/Web/ConfirmDeletion.php @@ -0,0 +1,116 @@ +token = $token; + $this->deletionRequest = AccountDeletionRequest::findValidByToken($token); + + if (! $this->deletionRequest) { + $this->step = 'invalid'; + + return; + } + + $this->userName = $this->deletionRequest->user->name; + + // Even if logged in, require re-authentication for security + $this->step = 'verify'; + } + + public function verifyPassword(): void + { + $this->error = ''; + + if (! $this->deletionRequest || ! $this->deletionRequest->isActive()) { + $this->step = 'invalid'; + + return; + } + + $user = $this->deletionRequest->user; + + if (! Hash::check($this->password, $user->password)) { + $this->error = 'The password you entered is incorrect.'; + + return; + } + + // Log the user in for this session + Auth::login($user); + $this->step = 'confirm'; + } + + public function confirmDeletion(): void + { + if (! $this->deletionRequest || ! $this->deletionRequest->isActive()) { + $this->step = 'invalid'; + + return; + } + + $this->step = 'deleting'; + + // Process deletion in background after animation starts + $this->dispatch('start-deletion'); + } + + public function executeDelete(): void + { + if (! $this->deletionRequest || ! $this->deletionRequest->isActive()) { + return; + } + + $user = $this->deletionRequest->user; + + DB::transaction(function () use ($user) { + // Mark request as confirmed and completed + $this->deletionRequest->confirm(); + $this->deletionRequest->complete(); + + // Delete all workspaces owned by the user + if (method_exists($user, 'ownedWorkspaces')) { + $user->ownedWorkspaces()->each(function ($workspace) { + $workspace->delete(); + }); + } + + // Hard delete user account + $user->forceDelete(); + }); + + Auth::logout(); + session()->invalidate(); + session()->regenerateToken(); + + $this->step = 'goodbye'; + } + + public function render() + { + return view('tenant::web.account.confirm-deletion'); + } +} diff --git a/src/View/Modal/Web/WorkspaceHome.php b/src/View/Modal/Web/WorkspaceHome.php new file mode 100644 index 0000000..7176030 --- /dev/null +++ b/src/View/Modal/Web/WorkspaceHome.php @@ -0,0 +1,67 @@ +attributes->get('workspace', 'main'); + + $this->workspace = $workspaceService->get($slug) ?? $workspaceService->get('main'); + + // Load workspace content from native content + $this->loadContent(); + } + + protected function loadContent(): void + { + try { + $workspaceModel = Workspace::where('slug', $this->workspace['slug'])->first(); + if (! $workspaceModel) { + $this->content = ['posts' => [], 'pages' => []]; + $this->loading = false; + + return; + } + + $render = app(ContentRender::class); + $homepage = $render->getHomepage($workspaceModel); + + $this->content = [ + 'posts' => $homepage['posts'] ?? [], + 'pages' => [], // Pages not included in homepage response + ]; + } catch (\Exception $e) { + $this->content = [ + 'posts' => [], + 'pages' => [], + ]; + } + + $this->loading = false; + } + + public function render() + { + return view('tenant::web.workspace.home') + ->layout('components.layouts.workspace', [ + 'title' => $this->workspace['name'].' | Host UK', + 'workspace' => $this->workspace, + ]); + } +} diff --git a/storage/app/.gitignore b/storage/app/.gitignore deleted file mode 100644 index 8f4803c..0000000 --- a/storage/app/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!public/ -!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/app/public/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore deleted file mode 100644 index 05c4471..0000000 --- a/storage/framework/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -compiled.php -config.php -down -events.scanned.php -maintenance.php -routes.php -routes.scanned.php -schedule-* -services.json diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore deleted file mode 100644 index 01e4a6c..0000000 --- a/storage/framework/cache/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!data/ -!.gitignore diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/framework/cache/data/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/framework/sessions/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/framework/testing/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/framework/views/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/logs/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index 26e1310..0000000 --- a/tailwind.config.js +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./resources/**/*.blade.php", - "./resources/**/*.js", - ], - theme: { - extend: {}, - }, - plugins: [], -}; diff --git a/tests/Feature/.gitkeep b/tests/Feature/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/Feature/AccountDeletionTest.php b/tests/Feature/AccountDeletionTest.php new file mode 100644 index 0000000..7d9455b --- /dev/null +++ b/tests/Feature/AccountDeletionTest.php @@ -0,0 +1,334 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); +}); + +describe('AccountDeletionRequest Model', function () { + describe('createForUser()', function () { + it('creates a new deletion request', function () { + $request = AccountDeletionRequest::createForUser($this->user); + + expect($request)->toBeInstanceOf(AccountDeletionRequest::class) + ->and($request->user_id)->toBe($this->user->id) + ->and($request->token)->toHaveLength(64) + ->and($request->completed_at)->toBeNull() + ->and($request->cancelled_at)->toBeNull(); + }); + + it('sets expiry based on configured grace period', function () { + config(['tenant.deletion.grace_period_days' => 14]); + + $this->travelTo(now()->startOfDay()); + $request = AccountDeletionRequest::createForUser($this->user); + + // Expiry should be 14 days in the future + expect((int) abs($request->expires_at->startOfDay()->diffInDays(now()->startOfDay())))->toBe(14); + }); + + it('stores optional reason', function () { + $reason = 'Switching to competitor'; + + $request = AccountDeletionRequest::createForUser($this->user, $reason); + + expect($request->reason)->toBe($reason); + }); + + it('cancels existing pending requests', function () { + $oldRequest = AccountDeletionRequest::createForUser($this->user); + $oldRequestId = $oldRequest->id; + + $newRequest = AccountDeletionRequest::createForUser($this->user); + + expect(AccountDeletionRequest::find($oldRequestId))->toBeNull() + ->and($newRequest->id)->not->toBe($oldRequestId); + }); + + it('does not affect completed requests', function () { + $completedRequest = AccountDeletionRequest::createForUser($this->user); + $completedRequest->complete(); + + $newRequest = AccountDeletionRequest::createForUser($this->user); + + expect(AccountDeletionRequest::find($completedRequest->id))->not->toBeNull() + ->and($newRequest->id)->not->toBe($completedRequest->id); + }); + }); + + describe('findValidByToken()', function () { + it('finds valid request by token', function () { + $request = AccountDeletionRequest::createForUser($this->user); + + $found = AccountDeletionRequest::findValidByToken($request->token); + + expect($found)->not->toBeNull() + ->and($found->id)->toBe($request->id); + }); + + it('returns null for completed request', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->complete(); + + $found = AccountDeletionRequest::findValidByToken($request->token); + + expect($found)->toBeNull(); + }); + + it('returns null for cancelled request', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->cancel(); + + $found = AccountDeletionRequest::findValidByToken($request->token); + + expect($found)->toBeNull(); + }); + + it('returns null for invalid token', function () { + AccountDeletionRequest::createForUser($this->user); + + $found = AccountDeletionRequest::findValidByToken('invalid-token'); + + expect($found)->toBeNull(); + }); + }); + + describe('pendingAutoDelete()', function () { + it('returns requests past expiry date', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + + $pending = AccountDeletionRequest::pendingAutoDelete()->get(); + + expect($pending)->toHaveCount(1) + ->and($pending->first()->id)->toBe($request->id); + }); + + it('excludes requests not yet expired', function () { + AccountDeletionRequest::createForUser($this->user); + + $pending = AccountDeletionRequest::pendingAutoDelete()->get(); + + expect($pending)->toHaveCount(0); + }); + + it('excludes completed requests', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + $request->complete(); + + $pending = AccountDeletionRequest::pendingAutoDelete()->get(); + + expect($pending)->toHaveCount(0); + }); + + it('excludes cancelled requests', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + $request->cancel(); + + $pending = AccountDeletionRequest::pendingAutoDelete()->get(); + + expect($pending)->toHaveCount(0); + }); + }); + + describe('state methods', function () { + it('isActive returns true for pending requests', function () { + $request = AccountDeletionRequest::createForUser($this->user); + + expect($request->isActive())->toBeTrue(); + }); + + it('isActive returns false after completion', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->complete(); + + expect($request->isActive())->toBeFalse(); + }); + + it('isActive returns false after cancellation', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->cancel(); + + expect($request->isActive())->toBeFalse(); + }); + + it('isPending returns true for future expiry', function () { + $request = AccountDeletionRequest::createForUser($this->user); + + expect($request->isPending())->toBeTrue(); + }); + + it('isReadyForAutoDeletion returns true for past expiry', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + + expect($request->isReadyForAutoDeletion())->toBeTrue(); + }); + }); + + describe('time helpers', function () { + it('calculates days remaining approximately', function () { + $this->travelTo(now()->startOfDay()); + + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->startOfDay()->addDays(5)]); + + // Use startOfDay to avoid timing issues + expect($request->daysRemaining())->toBeGreaterThanOrEqual(4) + ->and($request->daysRemaining())->toBeLessThanOrEqual(5); + }); + + it('calculates hours remaining approximately', function () { + $this->travelTo(now()->startOfHour()); + + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->startOfHour()->addHours(48)]); + + expect($request->hoursRemaining())->toBeGreaterThanOrEqual(47) + ->and($request->hoursRemaining())->toBeLessThanOrEqual(48); + }); + + it('returns zero for past expiry', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDays(2)]); + + expect($request->daysRemaining())->toBe(0) + ->and($request->hoursRemaining())->toBe(0); + }); + }); + + describe('URL helpers', function () { + it('generates confirmation URL with token', function () { + $request = AccountDeletionRequest::createForUser($this->user); + + $url = $request->confirmationUrl(); + + expect($url)->toContain($request->token) + ->and($url)->toContain('account/delete'); + }); + + it('generates cancel URL with token', function () { + $request = AccountDeletionRequest::createForUser($this->user); + + $url = $request->cancelUrl(); + + expect($url)->toContain($request->token) + ->and($url)->toContain('cancel'); + }); + }); +}); + +describe('ProcessAccountDeletion Job', function () { + it('deletes user account', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + + $job = new ProcessAccountDeletion($request); + $job->handle(); + + // User should be deleted + expect(User::find($this->user->id))->toBeNull(); + + // Note: AccountDeletionRequest is also deleted due to CASCADE constraint + // This is expected behaviour as we want the request deleted when user is deleted + }); + + it('deletes user workspaces', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + $workspaceId = $this->workspace->id; + + $job = new ProcessAccountDeletion($request); + $job->handle(); + + expect(Workspace::find($workspaceId))->toBeNull(); + }); + + it('skips if request no longer active', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->cancel(); + + $job = new ProcessAccountDeletion($request); + $job->handle(); + + expect(User::find($this->user->id))->not->toBeNull(); + }); + + it('handles missing user gracefully', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $this->user->forceDelete(); + + // Request is deleted due to CASCADE, job should handle this gracefully + $job = new ProcessAccountDeletion($request); + + // Should not throw + $job->handle(); + + // Just verify user is still gone + expect(User::find($this->user->id))->toBeNull(); + }); +}); + +describe('ProcessAccountDeletions Command', function () { + it('processes expired deletion requests', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + + $this->artisan('accounts:process-deletions') + ->assertSuccessful() + ->expectsOutputToContain('1 deleted'); + + expect(User::find($this->user->id))->toBeNull(); + }); + + it('skips non-expired requests', function () { + AccountDeletionRequest::createForUser($this->user); + + $this->artisan('accounts:process-deletions') + ->assertSuccessful() + ->expectsOutputToContain('No pending account deletions'); + + expect(User::find($this->user->id))->not->toBeNull(); + }); + + it('supports dry-run mode', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + + $this->artisan('accounts:process-deletions', ['--dry-run' => true]) + ->assertSuccessful() + ->expectsOutputToContain('DRY RUN'); + + // User should still exist + expect(User::find($this->user->id))->not->toBeNull(); + }); + + it('supports queue mode', function () { + Queue::fake(); + + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + + $this->artisan('accounts:process-deletions', ['--queue' => true]) + ->assertSuccessful() + ->expectsOutputToContain('queued'); + + Queue::assertPushed(ProcessAccountDeletion::class); + }); +}); diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php new file mode 100644 index 0000000..f165040 --- /dev/null +++ b/tests/Feature/AuthenticationTest.php @@ -0,0 +1,124 @@ +create($attributes); + } + + public function test_login_page_is_accessible(): void + { + $response = $this->get('/login'); + + $response->assertStatus(200); + $response->assertSee('Sign in to Host UK'); + } + + public function test_guests_are_redirected_from_hub_to_login(): void + { + $response = $this->get('/hub'); + + $response->assertRedirect('/login'); + } + + public function test_guests_are_redirected_from_hub_dashboard_to_login(): void + { + $response = $this->get('/hub/dashboard'); + + $response->assertRedirect('/login'); + } + + public function test_user_can_login_with_valid_credentials(): void + { + $user = $this->createUser([ + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + + Livewire::test(Login::class) + ->set('email', 'test@example.com') + ->set('password', 'password') + ->call('login') + ->assertRedirect(route('hub.home')); + + $this->assertAuthenticated(); + } + + public function test_user_cannot_login_with_invalid_credentials(): void + { + $user = $this->createUser([ + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + + Livewire::test(Login::class) + ->set('email', 'test@example.com') + ->set('password', 'wrong-password') + ->call('login') + ->assertHasErrors('email'); + + $this->assertGuest(); + } + + public function test_authenticated_user_is_redirected_from_login_to_hub(): void + { + $user = $this->createUser(); + + $response = $this->actingAs($user)->get('/login'); + + $response->assertRedirect('/hub'); + } + + public function test_authenticated_user_can_access_hub(): void + { + $user = $this->createUser(); + + $response = $this->actingAs($user)->get('/hub'); + + $response->assertStatus(200); + } + + public function test_user_can_logout_via_post(): void + { + $user = $this->createUser(); + + $this->actingAs($user); + $this->assertAuthenticated(); + + $response = $this->post('/logout'); + + $this->assertGuest(); + $response->assertRedirect('/'); + } + + public function test_user_can_logout_via_get(): void + { + $user = $this->createUser(); + + $this->actingAs($user); + $this->assertAuthenticated(); + + $response = $this->get('/logout'); + + $this->assertGuest(); + $response->assertRedirect('/'); + } + + public function test_marketing_page_is_accessible_without_auth(): void + { + $response = $this->get('/'); + + $response->assertStatus(200); + } +} diff --git a/tests/Feature/EntitlementApiTest.php b/tests/Feature/EntitlementApiTest.php new file mode 100644 index 0000000..19880f0 --- /dev/null +++ b/tests/Feature/EntitlementApiTest.php @@ -0,0 +1,251 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Create features + $this->socialAccountsFeature = Feature::create([ + 'code' => 'social.accounts', + 'name' => 'Social Accounts', + 'description' => 'Connected social accounts', + 'category' => 'social', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + 'is_active' => true, + ]); + + $this->socialPostsFeature = Feature::create([ + 'code' => 'social.posts.scheduled', + 'name' => 'Scheduled Posts', + 'description' => 'Monthly scheduled posts', + 'category' => 'social', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_MONTHLY, + 'is_active' => true, + ]); + + // Create package + $this->creatorPackage = Package::create([ + 'code' => 'social-creator', + 'name' => 'SocialHost Creator', + 'description' => 'For individual creators', + 'is_stackable' => false, + 'is_base_package' => true, + 'is_active' => true, + ]); + + $this->creatorPackage->features()->attach($this->socialAccountsFeature->id, ['limit_value' => 5]); + $this->creatorPackage->features()->attach($this->socialPostsFeature->id, ['limit_value' => 30]); + + $this->service = app(EntitlementService::class); +}); + +describe('Entitlement API', function () { + describe('GET /api/v1/entitlements/check', function () { + it('requires authentication', function () { + $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts'); + + $response->assertStatus(401); + }); + + it('returns 404 for non-existent user', function () { + $this->actingAs($this->user); + + $response = $this->getJson('/api/v1/entitlements/check?email=nonexistent@example.com&feature=social.accounts'); + + $response->assertStatus(404) + ->assertJson([ + 'allowed' => false, + 'reason' => 'User not found', + ]); + }); + + it('returns 404 when user has no workspace', function () { + $this->actingAs($this->user); + $this->workspace->users()->detach($this->user->id); + + $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts'); + + $response->assertStatus(404) + ->assertJson([ + 'allowed' => false, + 'reason' => 'No workspace found for user', + ]); + }); + + it('denies when user has no package', function () { + $this->actingAs($this->user); + + $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts'); + + $response->assertStatus(200) + ->assertJson([ + 'allowed' => false, + 'feature_code' => 'social.accounts', + ]); + }); + + it('allows when user has package with feature', function () { + $this->actingAs($this->user); + $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts'); + + $response->assertStatus(200) + ->assertJson([ + 'allowed' => true, + 'limit' => 5, + 'used' => 0, + 'remaining' => 5, + 'unlimited' => false, + 'feature_code' => 'social.accounts', + 'workspace_id' => $this->workspace->id, + ]); + }); + + it('respects quantity parameter', function () { + $this->actingAs($this->user); + $this->service->provisionPackage($this->workspace, 'social-creator'); + + // Use 4 of 5 allowed + $this->service->recordUsage($this->workspace, 'social.accounts', quantity: 4); + Cache::flush(); + + // Request 2 more (exceeds remaining) + $response = $this->getJson('/api/v1/entitlements/check?email='.$this->user->email.'&feature=social.accounts&quantity=2'); + + $response->assertStatus(200) + ->assertJson([ + 'allowed' => false, + 'remaining' => 1, + ]); + }); + }); + + describe('POST /api/v1/entitlements/usage', function () { + it('requires authentication', function () { + $response = $this->postJson('/api/v1/entitlements/usage', [ + 'email' => $this->user->email, + 'feature' => 'social.posts.scheduled', + ]); + + $response->assertStatus(401); + }); + + it('records usage successfully', function () { + $this->actingAs($this->user); + $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->postJson('/api/v1/entitlements/usage', [ + 'email' => $this->user->email, + 'feature' => 'social.posts.scheduled', + 'quantity' => 3, + ]); + + $response->assertStatus(201) + ->assertJson([ + 'success' => true, + 'feature_code' => 'social.posts.scheduled', + 'quantity' => 3, + ]); + + // Verify usage was recorded + Cache::flush(); + $result = $this->service->can($this->workspace, 'social.posts.scheduled'); + expect($result->used)->toBe(3); + }); + + it('records usage with metadata', function () { + $this->actingAs($this->user); + $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->postJson('/api/v1/entitlements/usage', [ + 'email' => $this->user->email, + 'feature' => 'social.posts.scheduled', + 'metadata' => ['source' => 'biohost', 'post_id' => 'abc123'], + ]); + + $response->assertStatus(201) + ->assertJson([ + 'success' => true, + ]); + }); + }); + + describe('GET /api/v1/entitlements/summary', function () { + it('requires authentication', function () { + $response = $this->getJson('/api/v1/entitlements/summary'); + + $response->assertStatus(401); + }); + + it('returns summary for authenticated user', function () { + $this->actingAs($this->user); + $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->getJson('/api/v1/entitlements/summary'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'workspace_id', + 'packages', + 'features' => [ + 'social' => [ + '*' => ['code', 'name', 'limit', 'used', 'remaining', 'unlimited', 'percentage'], + ], + ], + 'boosts', + ]); + }); + + it('includes package information', function () { + $this->actingAs($this->user); + $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->getJson('/api/v1/entitlements/summary'); + + $response->assertStatus(200); + + $packages = $response->json('packages'); + expect($packages)->toHaveCount(1); + expect($packages[0]['code'])->toBe('social-creator'); + }); + }); + + describe('GET /api/v1/entitlements/summary/{workspace}', function () { + it('requires authentication', function () { + $response = $this->getJson('/api/v1/entitlements/summary/'.$this->workspace->id); + + $response->assertStatus(401); + }); + + it('returns summary for specified workspace', function () { + $this->actingAs($this->user); + $this->service->provisionPackage($this->workspace, 'social-creator'); + + $response = $this->getJson('/api/v1/entitlements/summary/'.$this->workspace->id); + + $response->assertStatus(200) + ->assertJson([ + 'workspace_id' => $this->workspace->id, + ]); + }); + }); +}); diff --git a/tests/Feature/EntitlementServiceTest.php b/tests/Feature/EntitlementServiceTest.php new file mode 100644 index 0000000..964318e --- /dev/null +++ b/tests/Feature/EntitlementServiceTest.php @@ -0,0 +1,641 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Create features + $this->aiCreditsFeature = Feature::create([ + 'code' => 'ai.credits', + 'name' => 'AI Credits', + 'description' => 'AI generation credits', + 'category' => 'ai', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_MONTHLY, + 'is_active' => true, + 'sort_order' => 1, + ]); + + $this->apolloTierFeature = Feature::create([ + 'code' => 'tier.apollo', + 'name' => 'Apollo Tier', + 'description' => 'Apollo tier access', + 'category' => 'tier', + 'type' => Feature::TYPE_BOOLEAN, + 'reset_type' => Feature::RESET_NONE, + 'is_active' => true, + 'sort_order' => 1, + ]); + + $this->socialPostsFeature = Feature::create([ + 'code' => 'social.posts', + 'name' => 'Scheduled Posts', + 'description' => 'Monthly scheduled posts', + 'category' => 'social', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_MONTHLY, + 'is_active' => true, + 'sort_order' => 1, + ]); + + // Create packages + $this->creatorPackage = Package::create([ + 'code' => 'creator', + 'name' => 'Creator', + 'description' => 'For individual creators', + 'is_stackable' => false, + 'is_base_package' => true, + 'is_active' => true, + 'is_public' => true, + 'sort_order' => 1, + ]); + + $this->agencyPackage = Package::create([ + 'code' => 'agency', + 'name' => 'Agency', + 'description' => 'For agencies', + 'is_stackable' => false, + 'is_base_package' => true, + 'is_active' => true, + 'is_public' => true, + 'sort_order' => 2, + ]); + + // Attach features to packages + $this->creatorPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 100]); + $this->creatorPackage->features()->attach($this->apolloTierFeature->id, ['limit_value' => null]); + $this->creatorPackage->features()->attach($this->socialPostsFeature->id, ['limit_value' => 50]); + + $this->agencyPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 500]); + $this->agencyPackage->features()->attach($this->apolloTierFeature->id, ['limit_value' => null]); + $this->agencyPackage->features()->attach($this->socialPostsFeature->id, ['limit_value' => 200]); + + $this->service = app(EntitlementService::class); +}); + +describe('EntitlementService', function () { + describe('can() method', function () { + it('denies access when workspace has no packages', function () { + $result = $this->service->can($this->workspace, 'ai.credits'); + + expect($result)->toBeInstanceOf(EntitlementResult::class) + ->and($result->isAllowed())->toBeFalse() + ->and($result->isDenied())->toBeTrue() + ->and($result->reason)->toContain('plan does not include'); + }); + + it('allows access when workspace has package with feature', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + $result = $this->service->can($this->workspace, 'ai.credits'); + + expect($result->isAllowed())->toBeTrue() + ->and($result->limit)->toBe(100) + ->and($result->used)->toBe(0); + }); + + it('allows boolean features without limits', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + $result = $this->service->can($this->workspace, 'tier.apollo'); + + expect($result->isAllowed())->toBeTrue() + ->and($result->limit)->toBeNull(); + }); + + it('denies access when limit is exceeded', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + // Record usage up to the limit + for ($i = 0; $i < 100; $i++) { + UsageRecord::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'quantity' => 1, + 'recorded_at' => now(), + ]); + } + + Cache::flush(); + $result = $this->service->can($this->workspace, 'ai.credits'); + + expect($result->isDenied())->toBeTrue() + ->and($result->used)->toBe(100) + ->and($result->limit)->toBe(100) + ->and($result->reason)->toContain('reached your'); + }); + + it('allows access when quantity is within remaining limit', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + // Use 50 credits + UsageRecord::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'quantity' => 50, + 'recorded_at' => now(), + ]); + + Cache::flush(); + $result = $this->service->can($this->workspace, 'ai.credits', quantity: 25); + + expect($result->isAllowed())->toBeTrue() + ->and($result->remaining)->toBe(50); + }); + + it('denies access when requested quantity exceeds remaining', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + // Use 90 credits + UsageRecord::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'quantity' => 90, + 'recorded_at' => now(), + ]); + + Cache::flush(); + $result = $this->service->can($this->workspace, 'ai.credits', quantity: 20); + + expect($result->isDenied())->toBeTrue() + ->and($result->used)->toBe(90) + ->and($result->remaining)->toBe(10); + }); + + it('denies access for non-existent feature', function () { + $result = $this->service->can($this->workspace, 'non.existent.feature'); + + expect($result->isDenied())->toBeTrue() + ->and($result->reason)->toContain('does not exist'); + }); + }); + + describe('recordUsage() method', function () { + it('creates a usage record', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + $record = $this->service->recordUsage( + $this->workspace, + 'ai.credits', + quantity: 5, + user: $this->user + ); + + expect($record)->toBeInstanceOf(UsageRecord::class) + ->and($record->workspace_id)->toBe($this->workspace->id) + ->and($record->feature_code)->toBe('ai.credits') + ->and($record->quantity)->toBe(5) + ->and($record->user_id)->toBe($this->user->id); + }); + + it('records usage with metadata', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + $record = $this->service->recordUsage( + $this->workspace, + 'ai.credits', + quantity: 1, + metadata: ['model' => 'claude-3', 'tokens' => 1500] + ); + + expect($record->metadata)->toBe(['model' => 'claude-3', 'tokens' => 1500]); + }); + + it('invalidates cache after recording usage', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + // Warm up cache + $this->service->can($this->workspace, 'ai.credits'); + + // Record usage + $this->service->recordUsage($this->workspace, 'ai.credits', quantity: 10); + + // Check that usage is reflected (cache was invalidated) + $result = $this->service->can($this->workspace, 'ai.credits'); + + expect($result->used)->toBe(10); + }); + }); + + describe('provisionPackage() method', function () { + it('provisions a package to workspace', function () { + $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); + + expect($workspacePackage)->toBeInstanceOf(WorkspacePackage::class) + ->and($workspacePackage->workspace_id)->toBe($this->workspace->id) + ->and($workspacePackage->package->code)->toBe('creator') + ->and($workspacePackage->status)->toBe(WorkspacePackage::STATUS_ACTIVE); + }); + + it('creates an entitlement log entry', function () { + $this->service->provisionPackage($this->workspace, 'creator', [ + 'source' => EntitlementLog::SOURCE_BLESTA, + ]); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_PROVISIONED) + ->first(); + + expect($log)->not->toBeNull() + ->and($log->source)->toBe(EntitlementLog::SOURCE_BLESTA); + }); + + it('replaces existing base package when provisioning new base package', function () { + // Provision creator package + $creatorWp = $this->service->provisionPackage($this->workspace, 'creator'); + + // Provision agency package (should cancel creator) + $agencyWp = $this->service->provisionPackage($this->workspace, 'agency'); + + // Refresh creator package + $creatorWp->refresh(); + + expect($creatorWp->status)->toBe(WorkspacePackage::STATUS_CANCELLED) + ->and($agencyWp->status)->toBe(WorkspacePackage::STATUS_ACTIVE); + }); + + it('sets billing cycle anchor', function () { + $anchor = now()->subDays(15); + + $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => $anchor, + ]); + + expect($workspacePackage->billing_cycle_anchor->toDateString()) + ->toBe($anchor->toDateString()); + }); + + it('stores blesta service id', function () { + $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator', [ + 'blesta_service_id' => 'blesta_12345', + ]); + + expect($workspacePackage->blesta_service_id)->toBe('blesta_12345'); + }); + }); + + describe('provisionBoost() method', function () { + it('provisions a boost to workspace', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + $boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [ + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'limit_value' => 100, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + ]); + + expect($boost)->toBeInstanceOf(Boost::class) + ->and($boost->workspace_id)->toBe($this->workspace->id) + ->and($boost->feature_code)->toBe('ai.credits') + ->and($boost->limit_value)->toBe(100) + ->and($boost->status)->toBe(Boost::STATUS_ACTIVE); + }); + + it('adds boost limit to package limit', function () { + $this->service->provisionPackage($this->workspace, 'creator'); // 100 credits + + $this->service->provisionBoost($this->workspace, 'ai.credits', [ + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'limit_value' => 50, + ]); + + Cache::flush(); + $result = $this->service->can($this->workspace, 'ai.credits'); + + expect($result->limit)->toBe(150); // 100 + 50 + }); + + it('creates an entitlement log entry for boost', function () { + $this->service->provisionBoost($this->workspace, 'ai.credits', [ + 'limit_value' => 100, + ]); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_BOOST_PROVISIONED) + ->first(); + + expect($log)->not->toBeNull(); + }); + }); + + describe('suspendWorkspace() method', function () { + it('suspends all active packages', function () { + $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); + + $this->service->suspendWorkspace($this->workspace); + + $workspacePackage->refresh(); + + expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_SUSPENDED); + }); + + it('creates suspension log entries', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + $this->service->suspendWorkspace($this->workspace, EntitlementLog::SOURCE_BLESTA); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_SUSPENDED) + ->first(); + + expect($log)->not->toBeNull() + ->and($log->source)->toBe(EntitlementLog::SOURCE_BLESTA); + }); + + it('denies access after suspension', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + // Can access before suspension + expect($this->service->can($this->workspace, 'ai.credits')->isAllowed())->toBeTrue(); + + $this->service->suspendWorkspace($this->workspace); + Cache::flush(); + + // Cannot access after suspension + expect($this->service->can($this->workspace, 'ai.credits')->isDenied())->toBeTrue(); + }); + }); + + describe('reactivateWorkspace() method', function () { + it('reactivates suspended packages', function () { + $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); + $this->service->suspendWorkspace($this->workspace); + + $this->service->reactivateWorkspace($this->workspace); + + $workspacePackage->refresh(); + + expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_ACTIVE); + }); + + it('creates reactivation log entries', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + $this->service->suspendWorkspace($this->workspace); + + $this->service->reactivateWorkspace($this->workspace, EntitlementLog::SOURCE_BLESTA); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_REACTIVATED) + ->first(); + + expect($log)->not->toBeNull() + ->and($log->source)->toBe(EntitlementLog::SOURCE_BLESTA); + }); + + it('restores access after reactivation', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + $this->service->suspendWorkspace($this->workspace); + + $this->service->reactivateWorkspace($this->workspace); + Cache::flush(); + + expect($this->service->can($this->workspace, 'ai.credits')->isAllowed())->toBeTrue(); + }); + }); + + describe('getUsageSummary() method', function () { + it('returns usage summary for all features', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + $summary = $this->service->getUsageSummary($this->workspace); + + expect($summary)->toBeInstanceOf(\Illuminate\Support\Collection::class) + ->and($summary->has('ai'))->toBeTrue() + ->and($summary->has('tier'))->toBeTrue() + ->and($summary->has('social'))->toBeTrue(); + }); + + it('includes usage percentages', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + // Use 50 of 100 credits + $this->service->recordUsage($this->workspace, 'ai.credits', quantity: 50); + + $summary = $this->service->getUsageSummary($this->workspace); + $aiFeature = $summary->get('ai')->first(); + + expect($aiFeature['used'])->toBe(50) + ->and($aiFeature['limit'])->toBe(100) + ->and((int) $aiFeature['percentage'])->toBe(50); + }); + }); + + describe('getActivePackages() method', function () { + it('returns only active packages', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + $this->service->suspendWorkspace($this->workspace); + + $activePackages = $this->service->getActivePackages($this->workspace); + + expect($activePackages)->toHaveCount(0); + }); + + it('excludes expired packages', function () { + $wp = $this->service->provisionPackage($this->workspace, 'creator', [ + 'expires_at' => now()->subDay(), + ]); + + $activePackages = $this->service->getActivePackages($this->workspace); + + expect($activePackages)->toHaveCount(0); + }); + }); + + describe('getActiveBoosts() method', function () { + it('returns only active boosts', function () { + $boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [ + 'limit_value' => 100, + ]); + + $activeBoosts = $this->service->getActiveBoosts($this->workspace); + + expect($activeBoosts)->toHaveCount(1); + + // Cancel the boost + $boost->update(['status' => Boost::STATUS_CANCELLED]); + + $activeBoosts = $this->service->getActiveBoosts($this->workspace); + + expect($activeBoosts)->toHaveCount(0); + }); + }); + + describe('revokePackage() method', function () { + it('revokes an active package', function () { + $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); + + expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_ACTIVE); + + $this->service->revokePackage($this->workspace, 'creator'); + + $workspacePackage->refresh(); + + expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_CANCELLED) + ->and($workspacePackage->expires_at)->not->toBeNull(); + }); + + it('creates a cancellation log entry', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + $this->service->revokePackage($this->workspace, 'creator', EntitlementLog::SOURCE_SYSTEM); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED) + ->first(); + + expect($log)->not->toBeNull() + ->and($log->source)->toBe(EntitlementLog::SOURCE_SYSTEM); + }); + + it('denies access after package revocation', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + // Can access before revocation + expect($this->service->can($this->workspace, 'ai.credits')->isAllowed())->toBeTrue(); + + $this->service->revokePackage($this->workspace, 'creator'); + Cache::flush(); + + // Cannot access after revocation + expect($this->service->can($this->workspace, 'ai.credits')->isDenied())->toBeTrue(); + }); + + it('does nothing when package does not exist', function () { + // Should not throw, just return silently + $this->service->revokePackage($this->workspace, 'nonexistent-package'); + + // No log entries should be created + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED) + ->first(); + + expect($log)->toBeNull(); + }); + + it('does nothing when package already cancelled', function () { + $workspacePackage = $this->service->provisionPackage($this->workspace, 'creator'); + $workspacePackage->update(['status' => WorkspacePackage::STATUS_CANCELLED]); + + // Should not throw + $this->service->revokePackage($this->workspace, 'creator'); + + // Only one log entry (from provisioning, not cancellation) + $logs = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED) + ->count(); + + expect($logs)->toBe(0); + }); + + it('invalidates cache after revocation', function () { + $this->service->provisionPackage($this->workspace, 'creator'); + + // Warm up cache + $this->service->can($this->workspace, 'ai.credits'); + + // Revoke + $this->service->revokePackage($this->workspace, 'creator'); + + // Check that revocation is reflected (cache was invalidated) + $result = $this->service->can($this->workspace, 'ai.credits'); + + expect($result->isDenied())->toBeTrue(); + }); + }); + + describe('expireCycleBoundBoosts() method', function () { + it('expires cycle-bound boosts', function () { + $boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [ + 'limit_value' => 100, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + ]); + + $this->service->expireCycleBoundBoosts($this->workspace); + + $boost->refresh(); + + expect($boost->status)->toBe(Boost::STATUS_EXPIRED); + }); + + it('does not expire permanent boosts', function () { + $boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [ + 'limit_value' => 100, + 'duration_type' => Boost::DURATION_PERMANENT, + ]); + + $this->service->expireCycleBoundBoosts($this->workspace); + + $boost->refresh(); + + expect($boost->status)->toBe(Boost::STATUS_ACTIVE); + }); + + it('creates expiration log entries', function () { + $this->service->provisionBoost($this->workspace, 'ai.credits', [ + 'limit_value' => 100, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + ]); + + $this->service->expireCycleBoundBoosts($this->workspace); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_BOOST_EXPIRED) + ->first(); + + expect($log)->not->toBeNull(); + }); + }); +}); + +describe('EntitlementResult', function () { + it('calculates remaining correctly', function () { + $result = EntitlementResult::allowed(limit: 100, used: 75, featureCode: 'test'); + + expect($result->remaining)->toBe(25); + }); + + it('calculates usage percentage correctly', function () { + $result = EntitlementResult::allowed(limit: 100, used: 75, featureCode: 'test'); + + expect((int) $result->getUsagePercentage())->toBe(75); + }); + + it('identifies near limit correctly', function () { + $result = EntitlementResult::allowed(limit: 100, used: 85, featureCode: 'test'); + + expect($result->isNearLimit())->toBeTrue(); + + $result2 = EntitlementResult::allowed(limit: 100, used: 50, featureCode: 'test'); + + expect($result2->isNearLimit())->toBeFalse(); + }); + + it('identifies unlimited correctly', function () { + $result = EntitlementResult::unlimited('test'); + + expect($result->isUnlimited())->toBeTrue() + ->and($result->isAllowed())->toBeTrue(); + }); +}); diff --git a/tests/Feature/Guards/AccessTokenGuardTest.php b/tests/Feature/Guards/AccessTokenGuardTest.php new file mode 100644 index 0000000..31008b9 --- /dev/null +++ b/tests/Feature/Guards/AccessTokenGuardTest.php @@ -0,0 +1,180 @@ +create(); + $result = $user->createToken('Test Token'); + + // Test the guard directly by invoking it with a mock request + $guard = new \Core\Mod\Api\Guards\AccessTokenGuard(app('auth')); + $request = \Illuminate\Http\Request::create('/test', 'GET'); + $request->headers->set('Authorization', "Bearer {$result['token']}"); + + $authenticatedUser = $guard($request); + + expect($authenticatedUser)->not->toBeNull(); + expect($authenticatedUser->id)->toBe($user->id); +}); + +test('cannot authenticate with invalid token', function () { + $response = $this->getJson('/api/v1/social/posts', [ + 'Authorization' => 'Bearer invalid-token-that-does-not-exist', + ]); + + $response->assertUnauthorized(); +}); + +test('cannot authenticate with expired token', function () { + $user = User::factory()->create(); + $token = UserToken::factory() + ->for($user) + ->expired() + ->withToken('expired-token-12345') + ->create(); + + $response = $this->getJson('/api/v1/social/posts', [ + 'Authorization' => 'Bearer expired-token-12345', + ]); + + $response->assertUnauthorized(); +}); + +test('cannot authenticate without authorization header', function () { + $response = $this->getJson('/api/v1/social/posts'); + + $response->assertUnauthorized(); +}); + +test('token last_used_at is updated on successful authentication', function () { + $user = User::factory()->create(); + $result = $user->createToken('Test Token'); + $tokenModel = $result['model']; + + expect($tokenModel->last_used_at)->toBeNull(); + + // Test the guard directly by invoking it with a mock request + $guard = new \Core\Mod\Api\Guards\AccessTokenGuard(app('auth')); + $request = \Illuminate\Http\Request::create('/test', 'GET'); + $request->headers->set('Authorization', "Bearer {$result['token']}"); + + $guard($request); + + // Refresh the token model and check last_used_at was updated + $tokenModel->refresh(); + expect($tokenModel->last_used_at)->not->toBeNull(); + expect($tokenModel->last_used_at->timestamp)->toBeGreaterThan(now()->subMinute()->timestamp); +}); + +test('user can create multiple tokens with different names', function () { + $user = User::factory()->create(); + + $token1 = $user->createToken('Mobile App'); + $token2 = $user->createToken('Web Dashboard'); + $token3 = $user->createToken('CI/CD Pipeline'); + + expect($user->tokens)->toHaveCount(3); + expect($user->tokens->pluck('name')->toArray())->toBe([ + 'Mobile App', + 'Web Dashboard', + 'CI/CD Pipeline', + ]); +}); + +test('user can revoke a specific token', function () { + $user = User::factory()->create(); + + $token1 = $user->createToken('Token 1'); + $token2 = $user->createToken('Token 2'); + + expect($user->tokens)->toHaveCount(2); + + $user->revokeToken($token1['model']->id); + + $user->refresh(); + expect($user->tokens)->toHaveCount(1); + expect($user->tokens->first()->name)->toBe('Token 2'); +}); + +test('user can revoke all tokens', function () { + $user = User::factory()->create(); + + $user->createToken('Token 1'); + $user->createToken('Token 2'); + $user->createToken('Token 3'); + + expect($user->tokens)->toHaveCount(3); + + $user->revokeAllTokens(); + + $user->refresh(); + expect($user->tokens)->toHaveCount(0); +}); + +test('tokens are automatically deleted when user is deleted', function () { + $user = User::factory()->create(); + $result = $user->createToken('Test Token'); + $tokenId = $result['model']->id; + + expect(UserToken::find($tokenId))->not->toBeNull(); + + $user->delete(); + + // Token should be deleted due to CASCADE constraint + expect(UserToken::find($tokenId))->toBeNull(); +}); + +test('tokens are stored as hashed values', function () { + $user = User::factory()->create(); + $result = $user->createToken('Test Token'); + $plainToken = $result['token']; + $tokenModel = $result['model']; + + // The stored token should NOT match the plain text token + expect($tokenModel->token)->not->toBe($plainToken); + + // But it should match the SHA-256 hash + expect($tokenModel->token)->toBe(hash('sha256', $plainToken)); +}); + +test('can create token with expiry date', function () { + $user = User::factory()->create(); + $expiryDate = now()->addDays(30); + + $result = $user->createToken('Temporary Token', $expiryDate); + $tokenModel = $result['model']; + + expect($tokenModel->expires_at)->not->toBeNull(); + expect($tokenModel->expires_at->timestamp)->toBe($expiryDate->timestamp); + expect($tokenModel->isValid())->toBeTrue(); + expect($tokenModel->isExpired())->toBeFalse(); +}); + +test('expired tokens are marked as invalid', function () { + $token = UserToken::factory() + ->expired() + ->create(); + + expect($token->isExpired())->toBeTrue(); + expect($token->isValid())->toBeFalse(); +}); + +test('non-expired tokens are marked as valid', function () { + $token = UserToken::factory() + ->expiresIn(30) + ->create(); + + expect($token->isExpired())->toBeFalse(); + expect($token->isValid())->toBeTrue(); +}); + +test('tokens without expiry date are always valid', function () { + $token = UserToken::factory()->create(); + + expect($token->expires_at)->toBeNull(); + expect($token->isExpired())->toBeFalse(); + expect($token->isValid())->toBeTrue(); +}); diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php new file mode 100644 index 0000000..0c75a4b --- /dev/null +++ b/tests/Feature/ProfileTest.php @@ -0,0 +1,131 @@ +create($attributes); + } + + public function test_profile_page_is_accessible_when_authenticated(): void + { + $user = $this->createUser(); + + $response = $this->actingAs($user)->get('/hub/profile'); + + $response->assertStatus(200); + $response->assertSee('Usage'); + } + + public function test_profile_page_redirects_guests_to_login(): void + { + $response = $this->get('/hub/profile'); + + $response->assertRedirect('/login'); + } + + public function test_profile_displays_user_name(): void + { + $user = $this->createUser([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $component = Livewire::actingAs($user)->test(Profile::class); + + $this->assertEquals('Test User', $component->get('userName')); + $this->assertEquals('test@example.com', $component->get('userEmail')); + } + + public function test_profile_calculates_user_initials(): void + { + $user = $this->createUser(['name' => 'John Doe']); + + $component = Livewire::actingAs($user)->test(Profile::class); + + $this->assertEquals('JD', $component->get('userInitials')); + } + + public function test_profile_calculates_initials_for_single_name(): void + { + $user = $this->createUser(['name' => 'Madonna']); + + $component = Livewire::actingAs($user)->test(Profile::class); + + $this->assertEquals('M', $component->get('userInitials')); + } + + public function test_profile_loads_quotas(): void + { + $user = $this->createUser(); + + $component = Livewire::actingAs($user)->test(Profile::class); + + $quotas = $component->get('quotas'); + + $this->assertArrayHasKey('workspaces', $quotas); + $this->assertArrayHasKey('social_accounts', $quotas); + $this->assertArrayHasKey('scheduled_posts', $quotas); + $this->assertArrayHasKey('storage', $quotas); + } + + public function test_profile_loads_service_stats(): void + { + $user = $this->createUser(); + + $component = Livewire::actingAs($user)->test(Profile::class); + + $stats = $component->get('serviceStats'); + + $this->assertNotEmpty($stats); + $this->assertArrayHasKey('name', $stats[0]); + $this->assertArrayHasKey('icon', $stats[0]); + $this->assertArrayHasKey('color', $stats[0]); + $this->assertArrayHasKey('status', $stats[0]); + } + + public function test_profile_loads_recent_activity(): void + { + $user = $this->createUser(); + + $component = Livewire::actingAs($user)->test(Profile::class); + + $activity = $component->get('recentActivity'); + + $this->assertIsArray($activity); + } + + public function test_profile_shows_member_since_date(): void + { + $user = $this->createUser(); + + $component = Livewire::actingAs($user)->test(Profile::class); + + $memberSince = $component->get('memberSince'); + + $this->assertNotNull($memberSince); + $this->assertMatchesRegularExpression('/\w+ \d{4}/', $memberSince); + } + + public function test_profile_shows_user_tier(): void + { + $user = $this->createUser(); + + $component = Livewire::actingAs($user)->test(Profile::class); + + $userTier = $component->get('userTier'); + + $this->assertNotNull($userTier); + $this->assertContains($userTier, ['Free', 'Apollo', 'Hades']); + } +} diff --git a/tests/Feature/ResetBillingCyclesTest.php b/tests/Feature/ResetBillingCyclesTest.php new file mode 100644 index 0000000..6196ee5 --- /dev/null +++ b/tests/Feature/ResetBillingCyclesTest.php @@ -0,0 +1,462 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Create features + $this->aiCreditsFeature = Feature::create([ + 'code' => 'ai.credits', + 'name' => 'AI Credits', + 'description' => 'AI generation credits', + 'category' => 'ai', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_MONTHLY, + 'is_active' => true, + 'sort_order' => 1, + ]); + + $this->socialPostsFeature = Feature::create([ + 'code' => 'social.posts', + 'name' => 'Scheduled Posts', + 'description' => 'Monthly scheduled posts', + 'category' => 'social', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_MONTHLY, + 'is_active' => true, + 'sort_order' => 1, + ]); + + // Create base package + $this->creatorPackage = Package::create([ + 'code' => 'creator', + 'name' => 'Creator', + 'description' => 'For individual creators', + 'is_stackable' => false, + 'is_base_package' => true, + 'is_active' => true, + 'is_public' => true, + 'sort_order' => 1, + ]); + + $this->creatorPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 100]); + $this->creatorPackage->features()->attach($this->socialPostsFeature->id, ['limit_value' => 50]); + + $this->service = app(EntitlementService::class); +}); + +describe('ResetBillingCycles Command', function () { + describe('expiring cycle-bound boosts', function () { + it('expires cycle-bound boosts', function () { + // Provision package + $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => now()->startOfMonth(), + ]); + + // Create cycle-bound boost + $boost = Boost::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + 'limit_value' => 50, + 'consumed_quantity' => 10, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => now()->subDays(15), + ]); + + // Run command + $this->artisan('tenant:reset-billing-cycles', [ + '--workspace' => $this->workspace->id, + ])->assertExitCode(0); + + $boost->refresh(); + + expect($boost->status)->toBe(Boost::STATUS_EXPIRED); + }); + + it('does not expire permanent boosts', function () { + $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => now()->startOfMonth(), + ]); + + $boost = Boost::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => Boost::DURATION_PERMANENT, + 'limit_value' => 50, + 'consumed_quantity' => 0, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => now()->subDays(15), + ]); + + $this->artisan('tenant:reset-billing-cycles', [ + '--workspace' => $this->workspace->id, + ])->assertExitCode(0); + + $boost->refresh(); + + expect($boost->status)->toBe(Boost::STATUS_ACTIVE); + }); + + it('creates audit log entries for expired boosts', function () { + $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => now()->startOfMonth(), + ]); + + Boost::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + 'limit_value' => 50, + 'consumed_quantity' => 0, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => now()->subDays(15), + ]); + + $this->artisan('tenant:reset-billing-cycles', [ + '--workspace' => $this->workspace->id, + ])->assertExitCode(0); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_BOOST_EXPIRED) + ->first(); + + expect($log)->not->toBeNull() + ->and($log->metadata['reason'])->toBe('Billing cycle ended'); + }); + }); + + describe('expiring timed boosts', function () { + it('expires boosts past their expiry date', function () { + $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => now()->startOfMonth(), + ]); + + $boost = Boost::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => Boost::DURATION_DURATION, + 'limit_value' => 100, + 'consumed_quantity' => 0, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => now()->subDays(30), + 'expires_at' => now()->subDay(), // Expired yesterday + ]); + + $this->artisan('tenant:reset-billing-cycles', [ + '--workspace' => $this->workspace->id, + ])->assertExitCode(0); + + $boost->refresh(); + + expect($boost->status)->toBe(Boost::STATUS_EXPIRED); + }); + + it('does not expire boosts with future expiry', function () { + $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => now()->startOfMonth(), + ]); + + $boost = Boost::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => Boost::DURATION_DURATION, + 'limit_value' => 100, + 'consumed_quantity' => 0, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => now(), + 'expires_at' => now()->addWeek(), // Expires next week + ]); + + $this->artisan('tenant:reset-billing-cycles', [ + '--workspace' => $this->workspace->id, + ])->assertExitCode(0); + + $boost->refresh(); + + expect($boost->status)->toBe(Boost::STATUS_ACTIVE); + }); + }); + + describe('notifications', function () { + it('sends notification to workspace owner when boosts expire', function () { + $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => now()->startOfMonth(), + ]); + + Boost::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + 'limit_value' => 50, + 'consumed_quantity' => 10, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => now()->subDays(15), + ]); + + $this->artisan('tenant:reset-billing-cycles', [ + '--workspace' => $this->workspace->id, + ])->assertExitCode(0); + + Notification::assertSentTo( + $this->user, + BoostExpiredNotification::class + ); + }); + + it('does not send notification in dry-run mode', function () { + $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => now()->startOfMonth(), + ]); + + Boost::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + 'limit_value' => 50, + 'consumed_quantity' => 0, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => now()->subDays(15), + ]); + + $this->artisan('tenant:reset-billing-cycles', [ + '--workspace' => $this->workspace->id, + '--dry-run' => true, + ])->assertExitCode(0); + + Notification::assertNothingSent(); + }); + }); + + describe('dry-run mode', function () { + it('does not modify boosts in dry-run mode', function () { + $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => now()->startOfMonth(), + ]); + + $boost = Boost::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + 'limit_value' => 50, + 'consumed_quantity' => 0, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => now()->subDays(15), + ]); + + $this->artisan('tenant:reset-billing-cycles', [ + '--workspace' => $this->workspace->id, + '--dry-run' => true, + ])->assertExitCode(0); + + $boost->refresh(); + + expect($boost->status)->toBe(Boost::STATUS_ACTIVE); + }); + + it('does not create log entries in dry-run mode', function () { + $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => now()->startOfMonth(), + ]); + + Boost::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + 'limit_value' => 50, + 'consumed_quantity' => 0, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => now()->subDays(15), + ]); + + // Clear any existing logs + EntitlementLog::where('workspace_id', $this->workspace->id)->delete(); + + $this->artisan('tenant:reset-billing-cycles', [ + '--workspace' => $this->workspace->id, + '--dry-run' => true, + ])->assertExitCode(0); + + $logs = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', EntitlementLog::ACTION_BOOST_EXPIRED) + ->count(); + + expect($logs)->toBe(0); + }); + }); + + describe('processing all workspaces', function () { + it('processes multiple workspaces', function () { + // Create second workspace + $workspace2 = Workspace::factory()->create(['is_active' => true]); + $user2 = User::factory()->create(); + $workspace2->users()->attach($user2->id, ['role' => 'owner', 'is_default' => true]); + + // Provision packages for both + $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => now()->startOfMonth(), + ]); + $this->service->provisionPackage($workspace2, 'creator', [ + 'billing_cycle_anchor' => now()->startOfMonth(), + ]); + + // Create boosts for both + Boost::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + 'limit_value' => 50, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => now()->subDays(15), + ]); + + Boost::create([ + 'workspace_id' => $workspace2->id, + 'feature_code' => 'ai.credits', + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + 'limit_value' => 100, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => now()->subDays(15), + ]); + + $this->artisan('tenant:reset-billing-cycles') + ->assertExitCode(0); + + // Both boosts should be expired + expect(Boost::where('status', Boost::STATUS_EXPIRED)->count())->toBe(2); + }); + + it('skips workspaces without active packages', function () { + // Don't provision a package for this workspace + $workspace2 = Workspace::factory()->create(['is_active' => true]); + + $this->artisan('tenant:reset-billing-cycles') + ->assertExitCode(0); + + // No errors should occur + }); + + it('skips inactive workspaces', function () { + $this->workspace->update(['is_active' => false]); + + $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => now()->startOfMonth(), + ]); + + Boost::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + 'limit_value' => 50, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => now()->subDays(15), + ]); + + $this->artisan('tenant:reset-billing-cycles') + ->assertExitCode(0); + + // Boost should not be expired (workspace is inactive) + expect(Boost::where('status', Boost::STATUS_ACTIVE)->count())->toBe(1); + }); + }); + + describe('usage counter reset logging', function () { + it('logs cycle reset when at cycle boundary with previous usage', function () { + // Set billing cycle to start today + $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => now(), + ]); + + // Create usage record from previous cycle + UsageRecord::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'quantity' => 25, + 'recorded_at' => now()->subMonth(), // Previous cycle + ]); + + // Clear logs from provisioning + EntitlementLog::where('workspace_id', $this->workspace->id)->delete(); + + $this->artisan('tenant:reset-billing-cycles', [ + '--workspace' => $this->workspace->id, + ])->assertExitCode(0); + + $log = EntitlementLog::where('workspace_id', $this->workspace->id) + ->where('action', 'cycle.reset') + ->first(); + + expect($log)->not->toBeNull() + ->and($log->metadata['previous_cycle_records'])->toBe(1); + }); + }); + + describe('cache invalidation', function () { + it('invalidates entitlement cache after processing', function () { + $this->service->provisionPackage($this->workspace, 'creator', [ + 'billing_cycle_anchor' => now()->startOfMonth(), + ]); + + // Create and verify boost is counted in limit + $boost = Boost::create([ + 'workspace_id' => $this->workspace->id, + 'feature_code' => 'ai.credits', + 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => Boost::DURATION_CYCLE_BOUND, + 'limit_value' => 50, + 'consumed_quantity' => 0, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => now()->subDays(15), + ]); + + Cache::flush(); + $resultBefore = $this->service->can($this->workspace, 'ai.credits'); + + expect($resultBefore->limit)->toBe(150); // 100 + 50 boost + + // Run command + $this->artisan('tenant:reset-billing-cycles', [ + '--workspace' => $this->workspace->id, + ])->assertExitCode(0); + + // Limit should be back to package only + $resultAfter = $this->service->can($this->workspace, 'ai.credits'); + + expect($resultAfter->limit)->toBe(100); + }); + }); +}); diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php new file mode 100644 index 0000000..eeffc65 --- /dev/null +++ b/tests/Feature/SettingsTest.php @@ -0,0 +1,215 @@ +create($attributes); + } + + public function test_settings_page_is_accessible_when_authenticated(): void + { + $user = $this->createUser(); + + $response = $this->actingAs($user)->get('/hub/settings'); + + $response->assertStatus(200); + $response->assertSee('Account Settings'); + } + + public function test_settings_page_redirects_guests_to_login(): void + { + $response = $this->get('/hub/settings'); + + $response->assertRedirect('/login'); + } + + public function test_user_can_update_profile_information(): void + { + $user = $this->createUser([ + 'name' => 'Original Name', + 'email' => 'original@example.com', + ]); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('name', 'Updated Name') + ->set('email', 'updated@example.com') + ->call('updateProfile') + ->assertHasNoErrors(); + + $user->refresh(); + $this->assertEquals('Updated Name', $user->name); + $this->assertEquals('updated@example.com', $user->email); + } + + public function test_profile_update_validates_required_fields(): void + { + $user = $this->createUser(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('name', '') + ->set('email', '') + ->call('updateProfile') + ->assertHasErrors(['name', 'email']); + } + + public function test_profile_update_validates_email_format(): void + { + $user = $this->createUser(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('email', 'not-an-email') + ->call('updateProfile') + ->assertHasErrors(['email']); + } + + public function test_profile_update_validates_unique_email(): void + { + $existingUser = $this->createUser(['email' => 'existing@example.com']); + $user = $this->createUser(['email' => 'test@example.com']); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('email', 'existing@example.com') + ->call('updateProfile') + ->assertHasErrors(['email']); + } + + public function test_user_can_keep_same_email(): void + { + $user = $this->createUser(['email' => 'same@example.com']); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('name', 'New Name') + ->set('email', 'same@example.com') + ->call('updateProfile') + ->assertHasNoErrors(); + + $user->refresh(); + $this->assertEquals('same@example.com', $user->email); + } + + public function test_user_can_update_password(): void + { + $user = $this->createUser([ + 'password' => Hash::make('current-password'), + ]); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('current_password', 'current-password') + ->set('new_password', 'new-secure-password') + ->set('new_password_confirmation', 'new-secure-password') + ->call('updatePassword') + ->assertHasNoErrors(); + + $user->refresh(); + $this->assertTrue(Hash::check('new-secure-password', $user->password)); + } + + public function test_password_update_requires_current_password(): void + { + $user = $this->createUser([ + 'password' => Hash::make('current-password'), + ]); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('current_password', 'wrong-password') + ->set('new_password', 'new-secure-password') + ->set('new_password_confirmation', 'new-secure-password') + ->call('updatePassword') + ->assertHasErrors(['current_password']); + } + + public function test_password_update_requires_confirmation(): void + { + $user = $this->createUser([ + 'password' => Hash::make('current-password'), + ]); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('current_password', 'current-password') + ->set('new_password', 'new-secure-password') + ->set('new_password_confirmation', 'different-password') + ->call('updatePassword') + ->assertHasErrors(['new_password']); + } + + public function test_user_can_update_preferences(): void + { + $user = $this->createUser(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('timezone', 'America/New_York') + ->set('time_format', 24) + ->set('week_starts_on', 0) + ->call('updatePreferences') + ->assertHasNoErrors(); + + // Verify settings were saved + $timezoneSetting = Setting::where('user_id', $user->id) + ->where('name', 'timezone') + ->first(); + + $this->assertEquals('America/New_York', $timezoneSetting->payload); + } + + public function test_preferences_validates_timezone(): void + { + $user = $this->createUser(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('timezone', 'Invalid/Timezone') + ->call('updatePreferences') + ->assertHasErrors(['timezone']); + } + + public function test_settings_loads_existing_preferences(): void + { + $user = $this->createUser(); + + // Set some preferences + Setting::create([ + 'user_id' => $user->id, + 'name' => 'timezone', + 'payload' => 'Europe/London', + ]); + + $component = Livewire::actingAs($user)->test(Settings::class); + + $this->assertEquals('Europe/London', $component->get('timezone')); + } + + public function test_settings_shows_user_name_and_email(): void + { + $user = $this->createUser([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $component = Livewire::actingAs($user)->test(Settings::class); + + $this->assertEquals('Test User', $component->get('name')); + $this->assertEquals('test@example.com', $component->get('email')); + } +} diff --git a/tests/Feature/TwoFactorAuthenticatableTest.php b/tests/Feature/TwoFactorAuthenticatableTest.php new file mode 100644 index 0000000..fbfe594 --- /dev/null +++ b/tests/Feature/TwoFactorAuthenticatableTest.php @@ -0,0 +1,334 @@ +user = User::factory()->create(); +}); + +describe('TwoFactorAuthenticatable Trait', function () { + describe('twoFactorAuth() relationship', function () { + it('returns HasOne relationship', function () { + expect($this->user->twoFactorAuth())->toBeInstanceOf( + \Illuminate\Database\Eloquent\Relations\HasOne::class + ); + }); + + it('returns null when no 2FA record exists', function () { + expect($this->user->twoFactorAuth)->toBeNull(); + }); + + it('returns 2FA record when it exists', function () { + $twoFactorAuth = UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => ['code1', 'code2'], + 'confirmed_at' => now(), + ]); + + $this->user->refresh(); + + expect($this->user->twoFactorAuth)->toBeInstanceOf(UserTwoFactorAuth::class) + ->and($this->user->twoFactorAuth->id)->toBe($twoFactorAuth->id); + }); + }); + + describe('hasTwoFactorAuthEnabled()', function () { + it('returns false when no 2FA record exists', function () { + expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse(); + }); + + it('returns false when 2FA record exists but secret_key is null', function () { + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => null, + 'recovery_codes' => [], + 'confirmed_at' => now(), + ]); + + $this->user->refresh(); + + expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse(); + }); + + it('returns false when 2FA record exists but confirmed_at is null', function () { + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => [], + 'confirmed_at' => null, + ]); + + $this->user->refresh(); + + expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse(); + }); + + it('returns true when 2FA is fully enabled', function () { + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => ['code1', 'code2'], + 'confirmed_at' => now(), + ]); + + $this->user->refresh(); + + expect($this->user->hasTwoFactorAuthEnabled())->toBeTrue(); + }); + }); + + describe('twoFactorAuthSecretKey()', function () { + it('returns null when no 2FA record exists', function () { + expect($this->user->twoFactorAuthSecretKey())->toBeNull(); + }); + + it('returns the secret key when 2FA record exists', function () { + $secretKey = 'JBSWY3DPEHPK3PXP'; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => $secretKey, + 'recovery_codes' => [], + ]); + + $this->user->refresh(); + + expect($this->user->twoFactorAuthSecretKey())->toBe($secretKey); + }); + }); + + describe('twoFactorRecoveryCodes()', function () { + it('returns empty array when no 2FA record exists', function () { + expect($this->user->twoFactorRecoveryCodes())->toBe([]); + }); + + it('returns empty array when recovery_codes is null', function () { + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => null, + ]); + + $this->user->refresh(); + + expect($this->user->twoFactorRecoveryCodes())->toBe([]); + }); + + it('returns recovery codes as array', function () { + $codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3']; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => $codes, + ]); + + $this->user->refresh(); + + expect($this->user->twoFactorRecoveryCodes())->toBe($codes); + }); + }); + + describe('twoFactorReplaceRecoveryCode()', function () { + it('does nothing when no 2FA record exists', function () { + // Should not throw + $this->user->twoFactorReplaceRecoveryCode('nonexistent'); + + expect($this->user->twoFactorAuth)->toBeNull(); + }); + + it('does nothing when code is not found in recovery codes', function () { + $codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3']; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => $codes, + ]); + + $this->user->refresh(); + + $this->user->twoFactorReplaceRecoveryCode('NONEXISTENT'); + + $this->user->refresh(); + + expect($this->user->twoFactorRecoveryCodes())->toBe($codes); + }); + + it('replaces a used recovery code with a new one', function () { + $codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3']; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => $codes, + ]); + + $this->user->refresh(); + + $this->user->twoFactorReplaceRecoveryCode('CODE2-CODE2'); + + $this->user->refresh(); + $newCodes = $this->user->twoFactorRecoveryCodes(); + + // Should still have 3 codes + expect($newCodes)->toHaveCount(3) + // First and third codes should be unchanged + ->and($newCodes[0])->toBe('CODE1-CODE1') + ->and($newCodes[2])->toBe('CODE3-CODE3') + // Second code should be different and in the expected format + ->and($newCodes[1])->not->toBe('CODE2-CODE2') + ->and($newCodes[1])->toMatch('/^[A-F0-9]{10}-[A-F0-9]{10}$/'); + }); + }); + + describe('twoFactorQrCodeUrl()', function () { + it('generates valid TOTP URL', function () { + $secretKey = 'JBSWY3DPEHPK3PXP'; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => $secretKey, + 'recovery_codes' => [], + ]); + + $this->user->refresh(); + + $url = $this->user->twoFactorQrCodeUrl(); + + expect($url)->toStartWith('otpauth://totp/') + ->and($url)->toContain($secretKey) + ->and($url)->toContain(rawurlencode($this->user->email)) + ->and($url)->toContain('issuer='); + }); + + it('includes app name in the URL', function () { + $appName = config('app.name'); + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => [], + ]); + + $this->user->refresh(); + + $url = $this->user->twoFactorQrCodeUrl(); + + expect($url)->toContain(rawurlencode($appName)); + }); + }); + + describe('twoFactorQrCodeSvg()', function () { + it('returns empty string when no secret exists', function () { + expect($this->user->twoFactorQrCodeSvg())->toBe(''); + }); + + it('returns SVG content when secret exists', function () { + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => [], + ]); + + $this->user->refresh(); + + $svg = $this->user->twoFactorQrCodeSvg(); + + expect($svg)->toStartWith('and($svg)->toContain(''); + }); + }); + + describe('generateRecoveryCode() via twoFactorReplaceRecoveryCode()', function () { + it('generates codes in the expected format', function () { + $codes = ['TESTCODE1']; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => $codes, + ]); + + $this->user->refresh(); + + $this->user->twoFactorReplaceRecoveryCode('TESTCODE1'); + + $this->user->refresh(); + $newCode = $this->user->twoFactorRecoveryCodes()[0]; + + // Format: 10 uppercase hex chars - 10 uppercase hex chars + expect($newCode)->toMatch('/^[A-F0-9]{10}-[A-F0-9]{10}$/'); + }); + + it('generates unique codes', function () { + $codes = ['CODE1', 'CODE2', 'CODE3']; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => $codes, + ]); + + $this->user->refresh(); + + // Replace all codes + $this->user->twoFactorReplaceRecoveryCode('CODE1'); + $this->user->refresh(); + $this->user->twoFactorReplaceRecoveryCode('CODE2'); + $this->user->refresh(); + $this->user->twoFactorReplaceRecoveryCode('CODE3'); + $this->user->refresh(); + + $newCodes = $this->user->twoFactorRecoveryCodes(); + + // All codes should be unique + expect(array_unique($newCodes))->toHaveCount(3); + }); + }); +}); + +describe('UserTwoFactorAuth Model', function () { + it('belongs to a user', function () { + $twoFactorAuth = UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => [], + ]); + + expect($twoFactorAuth->user)->toBeInstanceOf(User::class) + ->and($twoFactorAuth->user->id)->toBe($this->user->id); + }); + + it('casts recovery_codes to collection', function () { + $codes = ['CODE1', 'CODE2']; + + $twoFactorAuth = UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => $codes, + ]); + + expect($twoFactorAuth->recovery_codes)->toBeInstanceOf(\Illuminate\Support\Collection::class) + ->and($twoFactorAuth->recovery_codes->toArray())->toBe($codes); + }); + + it('casts confirmed_at to datetime', function () { + $confirmedAt = now(); + + $twoFactorAuth = UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => [], + 'confirmed_at' => $confirmedAt, + ]); + + expect($twoFactorAuth->confirmed_at)->toBeInstanceOf(\Carbon\Carbon::class); + }); +}); diff --git a/tests/Feature/UsageAlertServiceTest.php b/tests/Feature/UsageAlertServiceTest.php new file mode 100644 index 0000000..463f090 --- /dev/null +++ b/tests/Feature/UsageAlertServiceTest.php @@ -0,0 +1,261 @@ +entitlementService = app(EntitlementService::class); + $this->alertService = app(UsageAlertService::class); + } + + public function test_it_sends_warning_alert_at_80_percent(): void + { + Notification::fake(); + + // Create feature with limit + $feature = Feature::factory()->create([ + 'code' => 'test.feature', + 'name' => 'Test Feature', + 'type' => Feature::TYPE_LIMIT, + ]); + + // Create package with limit of 10 + $package = Package::factory()->create(['code' => 'test-package', 'is_base_package' => true]); + $package->features()->attach($feature->id, ['limit_value' => 10]); + + // Create workspace with owner + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + $workspace->users()->attach($user->id, ['role' => 'owner']); + + // Provision package + $this->entitlementService->provisionPackage($workspace, 'test-package'); + + // Record 8 uses (80%) + for ($i = 0; $i < 8; $i++) { + $this->entitlementService->recordUsage($workspace, 'test.feature', 1); + } + + // Check for alerts + $result = $this->alertService->checkWorkspace($workspace); + + // Should send one alert + $this->assertEquals(1, $result['alerts_sent']); + + // Notification should be sent to owner + Notification::assertSentTo( + $user, + UsageAlertNotification::class, + fn ($notification) => $notification->threshold === UsageAlertHistory::THRESHOLD_WARNING + ); + + // Alert should be recorded + $this->assertDatabaseHas('entitlement_usage_alert_history', [ + 'workspace_id' => $workspace->id, + 'feature_code' => 'test.feature', + 'threshold' => 80, + ]); + } + + public function test_it_does_not_send_duplicate_alerts(): void + { + Notification::fake(); + + $feature = Feature::factory()->create([ + 'code' => 'test.feature', + 'name' => 'Test Feature', + 'type' => Feature::TYPE_LIMIT, + ]); + + $package = Package::factory()->create(['code' => 'test-package', 'is_base_package' => true]); + $package->features()->attach($feature->id, ['limit_value' => 10]); + + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + $workspace->users()->attach($user->id, ['role' => 'owner']); + + $this->entitlementService->provisionPackage($workspace, 'test-package'); + + // Record 8 uses (80%) + for ($i = 0; $i < 8; $i++) { + $this->entitlementService->recordUsage($workspace, 'test.feature', 1); + } + + // First check - should send alert + $result1 = $this->alertService->checkWorkspace($workspace); + $this->assertEquals(1, $result1['alerts_sent']); + + // Second check - should NOT send duplicate + $result2 = $this->alertService->checkWorkspace($workspace); + $this->assertEquals(0, $result2['alerts_sent']); + + // Only one notification should be sent + Notification::assertSentToTimes($user, UsageAlertNotification::class, 1); + } + + public function test_it_sends_escalating_alerts_at_different_thresholds(): void + { + Notification::fake(); + + $feature = Feature::factory()->create([ + 'code' => 'test.feature', + 'name' => 'Test Feature', + 'type' => Feature::TYPE_LIMIT, + ]); + + $package = Package::factory()->create(['code' => 'test-package', 'is_base_package' => true]); + $package->features()->attach($feature->id, ['limit_value' => 10]); + + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + $workspace->users()->attach($user->id, ['role' => 'owner']); + + $this->entitlementService->provisionPackage($workspace, 'test-package'); + + // Record 8 uses (80%) - warning + for ($i = 0; $i < 8; $i++) { + $this->entitlementService->recordUsage($workspace, 'test.feature', 1); + } + $this->alertService->checkWorkspace($workspace); + + // Record 1 more (90%) - critical + $this->entitlementService->recordUsage($workspace, 'test.feature', 1); + $result = $this->alertService->checkWorkspace($workspace); + $this->assertEquals(1, $result['alerts_sent']); + + // Record 1 more (100%) - limit reached + $this->entitlementService->recordUsage($workspace, 'test.feature', 1); + $result = $this->alertService->checkWorkspace($workspace); + $this->assertEquals(1, $result['alerts_sent']); + + // Should have 3 notifications total + Notification::assertSentToTimes($user, UsageAlertNotification::class, 3); + } + + public function test_it_resolves_alerts_when_usage_drops(): void + { + $feature = Feature::factory()->create([ + 'code' => 'test.feature', + 'name' => 'Test Feature', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_NONE, + ]); + + $workspace = Workspace::factory()->create(); + + // Create an unresolved alert + UsageAlertHistory::record( + workspaceId: $workspace->id, + featureCode: 'test.feature', + threshold: 80, + metadata: ['used' => 8, 'limit' => 10] + ); + + $this->assertDatabaseHas('entitlement_usage_alert_history', [ + 'workspace_id' => $workspace->id, + 'feature_code' => 'test.feature', + 'resolved_at' => null, + ]); + + // Resolve alerts + $resolved = UsageAlertHistory::resolveAllForFeature($workspace->id, 'test.feature'); + + $this->assertEquals(1, $resolved); + $this->assertDatabaseMissing('entitlement_usage_alert_history', [ + 'workspace_id' => $workspace->id, + 'feature_code' => 'test.feature', + 'resolved_at' => null, + ]); + } + + public function test_it_skips_unlimited_features(): void + { + Notification::fake(); + + $feature = Feature::factory()->create([ + 'code' => 'unlimited.feature', + 'name' => 'Unlimited Feature', + 'type' => Feature::TYPE_UNLIMITED, + ]); + + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + $workspace->users()->attach($user->id, ['role' => 'owner']); + + $result = $this->alertService->checkFeatureUsage($workspace, $feature); + + $this->assertFalse($result['alert_sent']); + Notification::assertNothingSent(); + } + + public function test_it_skips_boolean_features(): void + { + Notification::fake(); + + $feature = Feature::factory()->create([ + 'code' => 'boolean.feature', + 'name' => 'Boolean Feature', + 'type' => Feature::TYPE_BOOLEAN, + ]); + + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + $workspace->users()->attach($user->id, ['role' => 'owner']); + + // Boolean features should be skipped by the service + // since they don't have limits to check against + $result = $this->alertService->checkFeatureUsage($workspace, $feature); + + $this->assertFalse($result['alert_sent']); + Notification::assertNothingSent(); + } + + public function test_get_active_alerts_returns_unresolved_only(): void + { + $workspace = Workspace::factory()->create(); + + // Create resolved alert + $resolved = UsageAlertHistory::record( + workspaceId: $workspace->id, + featureCode: 'feature.a', + threshold: 80 + ); + $resolved->resolve(); + + // Create unresolved alert + UsageAlertHistory::record( + workspaceId: $workspace->id, + featureCode: 'feature.b', + threshold: 90 + ); + + $activeAlerts = $this->alertService->getActiveAlertsForWorkspace($workspace); + + $this->assertCount(1, $activeAlerts); + $this->assertEquals('feature.b', $activeAlerts->first()->feature_code); + } +} diff --git a/tests/Feature/WaitlistTest.php b/tests/Feature/WaitlistTest.php new file mode 100644 index 0000000..e415405 --- /dev/null +++ b/tests/Feature/WaitlistTest.php @@ -0,0 +1,181 @@ +get('/waitlist') + ->assertStatus(200) + ->assertSeeLivewire(Waitlist::class); + }); + + it('requires email', function () { + Livewire::test(Waitlist::class) + ->call('submit') + ->assertHasErrors(['email']); + }); + + it('validates email format', function () { + Livewire::test(Waitlist::class) + ->set('email', 'not-an-email') + ->call('submit') + ->assertHasErrors(['email']); + }); + + it('successfully creates waitlist entry', function () { + Livewire::test(Waitlist::class) + ->set('email', 'newuser@example.com') + ->set('name', 'New User') + ->set('interest', 'SocialHost') + ->call('submit') + ->assertHasNoErrors() + ->assertSet('submitted', true); + + $this->assertDatabaseHas('waitlist_entries', [ + 'email' => 'newuser@example.com', + 'name' => 'New User', + 'interest' => 'SocialHost', + ]); + }); + + it('shows position after signup', function () { + // Create some existing entries + WaitlistEntry::factory()->count(5)->create(); + + $component = Livewire::test(Waitlist::class) + ->set('email', 'position-test@example.com') + ->call('submit') + ->assertSet('submitted', true); + + expect($component->get('position'))->toBe(6); + }); + + it('rejects duplicate email', function () { + WaitlistEntry::factory()->create(['email' => 'existing@example.com']); + + Livewire::test(Waitlist::class) + ->set('email', 'existing@example.com') + ->call('submit') + ->assertHasErrors(['email']) + ->assertSet('submitted', false); + }); + + it('allows submission without name', function () { + Livewire::test(Waitlist::class) + ->set('email', 'noname@example.com') + ->call('submit') + ->assertHasNoErrors() + ->assertSet('submitted', true); + + $this->assertDatabaseHas('waitlist_entries', [ + 'email' => 'noname@example.com', + 'name' => null, + ]); + }); + + it('rate limits submissions', function () { + // Submit 3 times (the limit) + for ($i = 1; $i <= 3; $i++) { + Livewire::test(Waitlist::class) + ->set('email', "user{$i}@example.com") + ->call('submit') + ->assertHasNoErrors(); + } + + // 4th submission should be rate limited + Livewire::test(Waitlist::class) + ->set('email', 'user4@example.com') + ->call('submit') + ->assertHasErrors(['email']); + }); + + it('stores referer source', function () { + Livewire::test(Waitlist::class) + ->set('email', 'referer-test@example.com') + ->call('submit'); + + $entry = WaitlistEntry::where('email', 'referer-test@example.com')->first(); + expect($entry->source)->not->toBeNull(); + }); +}); + +describe('Waitlist Entry Model', function () { + it('can be created with factory', function () { + $entry = WaitlistEntry::factory()->create(); + + expect($entry)->toBeInstanceOf(WaitlistEntry::class) + ->and($entry->email)->not->toBeNull(); + }); + + it('generates invite code when inviting', function () { + $entry = WaitlistEntry::factory()->create([ + 'invite_code' => null, + 'invited_at' => null, + ]); + + expect($entry->invite_code)->toBeNull(); + + $entry->update([ + 'invite_code' => \Illuminate\Support\Str::random(16), + 'invited_at' => now(), + ]); + + expect($entry->invite_code)->not->toBeNull() + ->and(strlen($entry->invite_code))->toBe(16); + }); +}); + +describe('Waitlist Invite Notification', function () { + it('can be rendered', function () { + $entry = WaitlistEntry::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'invite_code' => 'TESTCODE123', + ]); + + $notification = new WaitlistInviteNotification($entry); + $mailMessage = $notification->toMail($entry); + + expect($mailMessage->subject)->toBe('Your Host UK invite is ready') + ->and($mailMessage->greeting)->toBe('Hello Test User,'); + }); + + it('uses fallback greeting without name', function () { + $entry = WaitlistEntry::factory()->create([ + 'name' => null, + 'email' => 'noname@example.com', + 'invite_code' => 'TESTCODE456', + ]); + + $notification = new WaitlistInviteNotification($entry); + $mailMessage = $notification->toMail($entry); + + expect($mailMessage->greeting)->toBe('Hello there,'); + }); + + it('is queued', function () { + Notification::fake(); + + $entry = WaitlistEntry::factory()->create([ + 'invite_code' => 'QUEUETEST123', + ]); + + $entry->notify(new WaitlistInviteNotification($entry)); + + Notification::assertSentTo($entry, WaitlistInviteNotification::class); + }); +}); diff --git a/tests/Feature/WorkspaceCacheTest.php b/tests/Feature/WorkspaceCacheTest.php new file mode 100644 index 0000000..3ca4cbf --- /dev/null +++ b/tests/Feature/WorkspaceCacheTest.php @@ -0,0 +1,584 @@ +cacheManager = app(WorkspaceCacheManager::class); + $this->cacheManager->setConfig([ + 'enabled' => true, + 'ttl' => 300, + 'prefix' => 'test_workspace_cache', + 'use_tags' => false, // Use non-tagged mode for tests (array driver doesn't support tags) + ]); + + // Enable strict mode for tests + WorkspaceScope::enableStrictMode(); + + // Create test data + $this->user = User::factory()->create(['name' => 'Test User']); + $this->workspace = Workspace::factory()->create(['name' => 'Test Workspace']); + $this->otherWorkspace = Workspace::factory()->create(['name' => 'Other Workspace']); + + $this->user->hostWorkspaces()->attach($this->workspace, ['role' => 'owner', 'is_default' => true]); + $this->user->hostWorkspaces()->attach($this->otherWorkspace, ['role' => 'member', 'is_default' => false]); + + // Clear any existing cache + Cache::flush(); + } + + protected function tearDown(): void + { + WorkspaceScope::enableStrictMode(); + WorkspaceCacheManager::resetKeyRegistry(); + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // WorkspaceCacheManager Basic Tests + // ------------------------------------------------------------------------- + + public function test_cache_manager_can_be_resolved(): void + { + $manager = app(WorkspaceCacheManager::class); + + $this->assertInstanceOf(WorkspaceCacheManager::class, $manager); + } + + public function test_cache_manager_generates_correct_keys(): void + { + $key = $this->cacheManager->key($this->workspace, 'test_key'); + + $this->assertStringContainsString((string) $this->workspace->id, $key); + $this->assertStringContainsString('test_key', $key); + $this->assertStringContainsString('test_workspace_cache', $key); + } + + public function test_cache_manager_workspace_tag_generation(): void + { + $tag = $this->cacheManager->workspaceTag($this->workspace); + + $this->assertStringContainsString((string) $this->workspace->id, $tag); + $this->assertStringContainsString('workspace', $tag); + } + + public function test_cache_manager_model_tag_generation(): void + { + $tag = $this->cacheManager->modelTag(Account::class); + + $this->assertStringContainsString('Account', $tag); + $this->assertStringContainsString('model', $tag); + } + + // ------------------------------------------------------------------------- + // Cache Hit/Miss Tests + // ------------------------------------------------------------------------- + + public function test_cache_remember_stores_and_retrieves_value(): void + { + $callCount = 0; + + // First call - should execute callback + $result1 = $this->cacheManager->remember($this->workspace, 'test', 300, function () use (&$callCount) { + $callCount++; + + return 'cached_value'; + }); + + // Second call - should use cache + $result2 = $this->cacheManager->remember($this->workspace, 'test', 300, function () use (&$callCount) { + $callCount++; + + return 'new_value'; + }); + + $this->assertEquals('cached_value', $result1); + $this->assertEquals('cached_value', $result2); + $this->assertEquals(1, $callCount, 'Callback should only be called once'); + } + + public function test_cache_miss_executes_callback(): void + { + $callCount = 0; + + $result = $this->cacheManager->remember($this->workspace, 'new_key', 300, function () use (&$callCount) { + $callCount++; + + return 'fresh_value'; + }); + + $this->assertEquals('fresh_value', $result); + $this->assertEquals(1, $callCount); + } + + public function test_cache_can_store_collections(): void + { + $collection = collect(['item1', 'item2', 'item3']); + + $this->cacheManager->put($this->workspace, 'collection_test', $collection, 300); + + $retrieved = $this->cacheManager->get($this->workspace, 'collection_test'); + + $this->assertInstanceOf(Collection::class, $retrieved); + $this->assertEquals($collection->toArray(), $retrieved->toArray()); + } + + public function test_cache_has_returns_correct_boolean(): void + { + $this->assertFalse($this->cacheManager->has($this->workspace, 'nonexistent')); + + $this->cacheManager->put($this->workspace, 'exists', 'value', 300); + + $this->assertTrue($this->cacheManager->has($this->workspace, 'exists')); + } + + // ------------------------------------------------------------------------- + // Cache Invalidation Tests + // ------------------------------------------------------------------------- + + public function test_cache_forget_removes_key(): void + { + $this->cacheManager->put($this->workspace, 'to_forget', 'value', 300); + $this->assertTrue($this->cacheManager->has($this->workspace, 'to_forget')); + + $result = $this->cacheManager->forget($this->workspace, 'to_forget'); + + $this->assertTrue($result); + $this->assertFalse($this->cacheManager->has($this->workspace, 'to_forget')); + } + + public function test_cache_flush_clears_all_workspace_keys(): void + { + // Store multiple keys + $this->cacheManager->put($this->workspace, 'key1', 'value1', 300); + $this->cacheManager->put($this->workspace, 'key2', 'value2', 300); + $this->cacheManager->put($this->workspace, 'key3', 'value3', 300); + + // Verify keys exist + $this->assertTrue($this->cacheManager->has($this->workspace, 'key1')); + $this->assertTrue($this->cacheManager->has($this->workspace, 'key2')); + $this->assertTrue($this->cacheManager->has($this->workspace, 'key3')); + + // Flush all keys for workspace + $this->cacheManager->flush($this->workspace); + + // Verify keys are gone + $this->assertFalse($this->cacheManager->has($this->workspace, 'key1')); + $this->assertFalse($this->cacheManager->has($this->workspace, 'key2')); + $this->assertFalse($this->cacheManager->has($this->workspace, 'key3')); + } + + public function test_model_save_clears_workspace_cache(): void + { + $this->actingAs($this->user); + request()->attributes->set('workspace_model', $this->workspace); + + // Create an account (bypassing strict mode for setup) + WorkspaceScope::withoutStrictMode(function () { + Account::factory()->create(['workspace_id' => $this->workspace->id]); + }); + + // Cache the collection + $cached = Account::ownedByCurrentWorkspaceCached(); + $this->assertCount(1, $cached); + + // Create another account - this should clear the cache + WorkspaceScope::withoutStrictMode(function () { + Account::factory()->create(['workspace_id' => $this->workspace->id]); + }); + + // Get the collection again - should reflect the new data + $refreshed = Account::ownedByCurrentWorkspaceCached(); + $this->assertCount(2, $refreshed); + } + + public function test_model_delete_clears_workspace_cache(): void + { + $this->actingAs($this->user); + request()->attributes->set('workspace_model', $this->workspace); + + // Create accounts + $account = null; + WorkspaceScope::withoutStrictMode(function () use (&$account) { + $account = Account::factory()->create(['workspace_id' => $this->workspace->id]); + Account::factory()->create(['workspace_id' => $this->workspace->id]); + }); + + // Cache the collection + $cached = Account::ownedByCurrentWorkspaceCached(); + $this->assertCount(2, $cached); + + // Delete one account - this should clear the cache + WorkspaceScope::withoutStrictMode(function () use ($account) { + $account->delete(); + }); + + // Get the collection again - should reflect the deletion + $refreshed = Account::ownedByCurrentWorkspaceCached(); + $this->assertCount(1, $refreshed); + } + + // ------------------------------------------------------------------------- + // Multi-Workspace Isolation Tests + // ------------------------------------------------------------------------- + + public function test_cache_is_isolated_between_workspaces(): void + { + // Store different values in different workspaces + $this->cacheManager->put($this->workspace, 'shared_key', 'workspace1_value', 300); + $this->cacheManager->put($this->otherWorkspace, 'shared_key', 'workspace2_value', 300); + + // Retrieve values + $value1 = $this->cacheManager->get($this->workspace, 'shared_key'); + $value2 = $this->cacheManager->get($this->otherWorkspace, 'shared_key'); + + $this->assertEquals('workspace1_value', $value1); + $this->assertEquals('workspace2_value', $value2); + } + + public function test_flush_only_affects_target_workspace(): void + { + // Store values in both workspaces + $this->cacheManager->put($this->workspace, 'key', 'value1', 300); + $this->cacheManager->put($this->otherWorkspace, 'key', 'value2', 300); + + // Flush only the first workspace + $this->cacheManager->flush($this->workspace); + + // First workspace key should be gone + $this->assertFalse($this->cacheManager->has($this->workspace, 'key')); + + // Other workspace key should still exist + $this->assertTrue($this->cacheManager->has($this->otherWorkspace, 'key')); + $this->assertEquals('value2', $this->cacheManager->get($this->otherWorkspace, 'key')); + } + + public function test_model_caching_respects_workspace_context(): void + { + $this->actingAs($this->user); + + // Create accounts in different workspaces + WorkspaceScope::withoutStrictMode(function () { + Account::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Account 1']); + Account::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Account 2']); + Account::factory()->create(['workspace_id' => $this->otherWorkspace->id, 'name' => 'Other Account']); + }); + + // Set context to first workspace + request()->attributes->set('workspace_model', $this->workspace); + + // Cache should only contain first workspace's accounts + $cached = Account::ownedByCurrentWorkspaceCached(); + $this->assertCount(2, $cached); + $this->assertTrue($cached->pluck('name')->contains('Account 1')); + $this->assertTrue($cached->pluck('name')->contains('Account 2')); + $this->assertFalse($cached->pluck('name')->contains('Other Account')); + + // Switch context to other workspace + request()->attributes->set('workspace_model', $this->otherWorkspace); + + // Cache should only contain other workspace's accounts + $otherCached = Account::ownedByCurrentWorkspaceCached(); + $this->assertCount(1, $otherCached); + $this->assertTrue($otherCached->pluck('name')->contains('Other Account')); + } + + // ------------------------------------------------------------------------- + // Configuration Tests + // ------------------------------------------------------------------------- + + public function test_cache_disabled_when_config_disabled(): void + { + $this->cacheManager->setConfig([ + 'enabled' => false, + 'ttl' => 300, + 'prefix' => 'test', + 'use_tags' => false, + ]); + + $callCount = 0; + + // Both calls should execute the callback because caching is disabled + $this->cacheManager->remember($this->workspace, 'test', 300, function () use (&$callCount) { + $callCount++; + + return 'value'; + }); + + $this->cacheManager->remember($this->workspace, 'test', 300, function () use (&$callCount) { + $callCount++; + + return 'value'; + }); + + $this->assertEquals(2, $callCount, 'Both calls should execute callback when cache is disabled'); + } + + public function test_default_ttl_used_when_null_passed(): void + { + $this->cacheManager->setConfig([ + 'enabled' => true, + 'ttl' => 600, + 'prefix' => 'test', + 'use_tags' => false, + ]); + + $this->assertEquals(600, $this->cacheManager->defaultTtl()); + } + + public function test_custom_prefix_used_in_keys(): void + { + $this->cacheManager->setConfig([ + 'enabled' => true, + 'ttl' => 300, + 'prefix' => 'custom_prefix', + 'use_tags' => false, + ]); + + $key = $this->cacheManager->key($this->workspace, 'test'); + + $this->assertStringContainsString('custom_prefix', $key); + } + + // ------------------------------------------------------------------------- + // Cache Statistics Tests + // ------------------------------------------------------------------------- + + public function test_stats_returns_workspace_cache_info(): void + { + $this->cacheManager->put($this->workspace, 'key1', 'value1', 300); + $this->cacheManager->put($this->workspace, 'key2', 'value2', 300); + + $stats = $this->cacheManager->stats($this->workspace); + + $this->assertEquals($this->workspace->id, $stats['workspace_id']); + $this->assertTrue($stats['enabled']); + $this->assertIsInt($stats['registered_keys']); + $this->assertIsArray($stats['keys']); + } + + public function test_get_registered_keys_returns_workspace_keys(): void + { + $this->cacheManager->put($this->workspace, 'key1', 'value1', 300); + $this->cacheManager->put($this->workspace, 'key2', 'value2', 300); + + $keys = $this->cacheManager->getRegisteredKeys($this->workspace); + + $this->assertCount(2, $keys); + } + + // ------------------------------------------------------------------------- + // HasWorkspaceCache Trait Tests + // ------------------------------------------------------------------------- + + public function test_has_workspace_cache_remember_for_workspace(): void + { + $this->actingAs($this->user); + request()->attributes->set('workspace_model', $this->workspace); + + // Create a model class that uses HasWorkspaceCache + $testModel = new class extends Model + { + use BelongsToWorkspace; + use HasWorkspaceCache; + + protected $table = 'test_cache_models'; + }; + + $callCount = 0; + + // First call - should execute callback + $result1 = $testModel::rememberForWorkspace('custom_key', 300, function () use (&$callCount) { + $callCount++; + + return collect(['item1', 'item2']); + }); + + // Second call - should use cache + $result2 = $testModel::rememberForWorkspace('custom_key', 300, function () use (&$callCount) { + $callCount++; + + return collect(['different']); + }); + + $this->assertEquals(['item1', 'item2'], $result1->toArray()); + $this->assertEquals(['item1', 'item2'], $result2->toArray()); + $this->assertEquals(1, $callCount); + } + + public function test_has_workspace_cache_forget_for_workspace(): void + { + $this->actingAs($this->user); + request()->attributes->set('workspace_model', $this->workspace); + + $testModel = new class extends Model + { + use BelongsToWorkspace; + use HasWorkspaceCache; + + protected $table = 'test_cache_models'; + }; + + // Store a value + $testModel::putForWorkspace('to_forget', 'value', 300); + $this->assertTrue($testModel::hasInWorkspaceCache('to_forget')); + + // Forget it + $testModel::forgetForWorkspace('to_forget'); + $this->assertFalse($testModel::hasInWorkspaceCache('to_forget')); + } + + public function test_has_workspace_cache_without_context_returns_callback_result(): void + { + // Ensure no workspace context + request()->attributes->remove('workspace_model'); + WorkspaceScope::disableStrictMode(); + + $testModel = new class extends Model + { + use BelongsToWorkspace; + use HasWorkspaceCache; + + protected $table = 'test_cache_models'; + + protected bool $workspaceContextRequired = false; + }; + + $callCount = 0; + + // Without context, should always execute callback (no caching) + $result = $testModel::rememberForWorkspace('key', 300, function () use (&$callCount) { + $callCount++; + + return 'uncached_value'; + }); + + $this->assertEquals('uncached_value', $result); + $this->assertEquals(1, $callCount); + + WorkspaceScope::enableStrictMode(); + } + + // ------------------------------------------------------------------------- + // BelongsToWorkspace Caching Tests + // ------------------------------------------------------------------------- + + public function test_owned_by_current_workspace_cached_uses_cache_manager(): void + { + $this->actingAs($this->user); + request()->attributes->set('workspace_model', $this->workspace); + + // Create an account + WorkspaceScope::withoutStrictMode(function () { + Account::factory()->create(['workspace_id' => $this->workspace->id]); + }); + + // First call - should cache + $result1 = Account::ownedByCurrentWorkspaceCached(); + + // Verify result + $this->assertCount(1, $result1); + + // Check that cache key was registered + $keys = $this->cacheManager->getRegisteredKeys($this->workspace); + $this->assertNotEmpty($keys); + } + + public function test_for_workspace_cached_caches_for_specific_workspace(): void + { + // Create accounts in the workspace + WorkspaceScope::withoutStrictMode(function () { + Account::factory()->count(3)->create(['workspace_id' => $this->workspace->id]); + }); + + $callCount = 0; + + // Manually test caching behavior + $firstCall = Account::forWorkspaceCached($this->workspace, 300); + $this->assertCount(3, $firstCall); + + // Second call should use cache (we can't easily verify this without mocking, + // but we can verify the result is consistent) + $secondCall = Account::forWorkspaceCached($this->workspace, 300); + $this->assertCount(3, $secondCall); + } + + public function test_workspace_cache_key_includes_model_name(): void + { + $key = Account::workspaceCacheKey($this->workspace->id); + + $this->assertStringContainsString('Account', $key); + $this->assertStringContainsString((string) $this->workspace->id, $key); + } + + public function test_clear_all_workspace_caches_clears_user_workspaces(): void + { + $this->actingAs($this->user); + + // Cache data in both workspaces + request()->attributes->set('workspace_model', $this->workspace); + WorkspaceScope::withoutStrictMode(function () { + Account::factory()->create(['workspace_id' => $this->workspace->id]); + }); + Account::ownedByCurrentWorkspaceCached(); + + request()->attributes->set('workspace_model', $this->otherWorkspace); + WorkspaceScope::withoutStrictMode(function () { + Account::factory()->create(['workspace_id' => $this->otherWorkspace->id]); + }); + Account::ownedByCurrentWorkspaceCached(); + + // Clear all caches for the model + Account::clearAllWorkspaceCaches(); + + // Note: Without tags, this clears cache for all workspaces the user has access to + // The cache should be empty for both workspaces now + $keys1 = $this->cacheManager->getRegisteredKeys($this->workspace); + $keys2 = $this->cacheManager->getRegisteredKeys($this->otherWorkspace); + + // After clearing, the registered keys should be empty or the cache values should be missing + // (depending on implementation details) + $this->assertCount(0, $keys1); + $this->assertCount(0, $keys2); + } +} diff --git a/tests/Feature/WorkspaceInvitationTest.php b/tests/Feature/WorkspaceInvitationTest.php new file mode 100644 index 0000000..9c088d5 --- /dev/null +++ b/tests/Feature/WorkspaceInvitationTest.php @@ -0,0 +1,192 @@ +create(); + $workspace = Workspace::factory()->create(); + $workspace->users()->attach($owner->id, ['role' => 'owner']); + + $invitation = $workspace->invite('newuser@example.com', 'member', $owner); + + $this->assertDatabaseHas('workspace_invitations', [ + 'workspace_id' => $workspace->id, + 'email' => 'newuser@example.com', + 'role' => 'member', + 'invited_by' => $owner->id, + ]); + + $this->assertNotNull($invitation->token); + $this->assertTrue($invitation->isPending()); + $this->assertFalse($invitation->isExpired()); + $this->assertFalse($invitation->isAccepted()); + + Notification::assertSentTo($invitation, WorkspaceInvitationNotification::class); + } + + public function test_invitation_expires_after_set_days(): void + { + $workspace = Workspace::factory()->create(); + $invitation = $workspace->invite('test@example.com', 'member', null, 3); + + $this->assertTrue($invitation->expires_at->isBetween( + now()->addDays(2)->addHours(23), + now()->addDays(3)->addHours(1) + )); + } + + public function test_user_can_accept_invitation(): void + { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(['email' => 'invited@example.com']); + + $invitation = WorkspaceInvitation::factory()->create([ + 'workspace_id' => $workspace->id, + 'email' => 'invited@example.com', + 'role' => 'admin', + ]); + + $result = $invitation->accept($user); + + $this->assertTrue($result); + $this->assertTrue($invitation->fresh()->isAccepted()); + $this->assertTrue($workspace->users()->where('user_id', $user->id)->exists()); + $this->assertEquals('admin', $workspace->users()->find($user->id)->pivot->role); + } + + public function test_expired_invitation_cannot_be_accepted(): void + { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + $invitation = WorkspaceInvitation::factory()->expired()->create([ + 'workspace_id' => $workspace->id, + ]); + + $result = $invitation->accept($user); + + $this->assertFalse($result); + $this->assertFalse($workspace->users()->where('user_id', $user->id)->exists()); + } + + public function test_already_accepted_invitation_cannot_be_reused(): void + { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + $invitation = WorkspaceInvitation::factory()->accepted()->create([ + 'workspace_id' => $workspace->id, + ]); + + $result = $invitation->accept($user); + + $this->assertFalse($result); + } + + public function test_resending_invitation_updates_existing(): void + { + Notification::fake(); + + $workspace = Workspace::factory()->create(); + $owner = User::factory()->create(); + + // First invitation as member + $first = $workspace->invite('test@example.com', 'member', $owner); + $firstToken = $first->token; + + // Second invitation as admin - should update existing + $second = $workspace->invite('test@example.com', 'admin', $owner); + + $this->assertEquals($first->id, $second->id); + $this->assertEquals($firstToken, $second->token); // Token unchanged + $this->assertEquals('admin', $second->role); + + // Should only have one invitation + $this->assertEquals(1, $workspace->invitations()->count()); + } + + public function test_static_accept_invitation_method(): void + { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + $invitation = WorkspaceInvitation::factory()->create([ + 'workspace_id' => $workspace->id, + 'role' => 'member', + ]); + + $result = Workspace::acceptInvitation($invitation->token, $user); + + $this->assertTrue($result); + $this->assertTrue($workspace->users()->where('user_id', $user->id)->exists()); + } + + public function test_static_accept_with_invalid_token_returns_false(): void + { + $user = User::factory()->create(); + + $result = Workspace::acceptInvitation('invalid-token', $user); + + $this->assertFalse($result); + } + + public function test_user_already_in_workspace_still_accepts(): void + { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + // User already in workspace + $workspace->users()->attach($user->id, ['role' => 'member']); + + $invitation = WorkspaceInvitation::factory()->create([ + 'workspace_id' => $workspace->id, + 'email' => $user->email, + 'role' => 'admin', + ]); + + $result = $invitation->accept($user); + + $this->assertTrue($result); + $this->assertTrue($invitation->fresh()->isAccepted()); + // Role should remain as original (member), not updated to admin + $this->assertEquals('member', $workspace->users()->find($user->id)->pivot->role); + } + + public function test_invitation_scopes(): void + { + $workspace = Workspace::factory()->create(); + + $pending = WorkspaceInvitation::factory()->create([ + 'workspace_id' => $workspace->id, + ]); + + $expired = WorkspaceInvitation::factory()->expired()->create([ + 'workspace_id' => $workspace->id, + ]); + + $accepted = WorkspaceInvitation::factory()->accepted()->create([ + 'workspace_id' => $workspace->id, + ]); + + $this->assertEquals(1, WorkspaceInvitation::pending()->count()); + $this->assertEquals(1, WorkspaceInvitation::expired()->count()); + $this->assertEquals(1, WorkspaceInvitation::accepted()->count()); + } +} diff --git a/tests/Feature/WorkspaceSecurityTest.php b/tests/Feature/WorkspaceSecurityTest.php new file mode 100644 index 0000000..a7a090b --- /dev/null +++ b/tests/Feature/WorkspaceSecurityTest.php @@ -0,0 +1,433 @@ +user = User::factory()->create(['name' => 'Test User']); + $this->workspace = Workspace::factory()->create(['name' => 'Test Workspace']); + $this->user->hostWorkspaces()->attach($this->workspace, ['role' => 'owner', 'is_default' => true]); + } + + protected function tearDown(): void + { + // Reset to default state + WorkspaceScope::enableStrictMode(); + parent::tearDown(); + } + + // ───────────────────────────────────────────────────────────────────────── + // MissingWorkspaceContextException Tests + // ───────────────────────────────────────────────────────────────────────── + + public function test_exception_for_model_has_correct_message(): void + { + $exception = MissingWorkspaceContextException::forModel('Account', 'query'); + + $this->assertStringContainsString('Account', $exception->getMessage()); + $this->assertStringContainsString('query', $exception->getMessage()); + $this->assertEquals('query', $exception->getOperation()); + $this->assertEquals('Account', $exception->getModel()); + } + + public function test_exception_for_create_has_correct_message(): void + { + $exception = MissingWorkspaceContextException::forCreate('Account'); + + $this->assertStringContainsString('Account', $exception->getMessage()); + $this->assertStringContainsString('create', $exception->getMessage()); + $this->assertEquals('create', $exception->getOperation()); + } + + public function test_exception_for_scope_has_correct_message(): void + { + $exception = MissingWorkspaceContextException::forScope('Account'); + + $this->assertStringContainsString('Account', $exception->getMessage()); + $this->assertStringContainsString('scope', $exception->getMessage()); + $this->assertEquals('scope', $exception->getOperation()); + } + + public function test_exception_renders_json_for_api_requests(): void + { + $exception = MissingWorkspaceContextException::forMiddleware(); + $request = Request::create('/api/test', 'GET'); + $request->headers->set('Accept', 'application/json'); + + $response = $exception->render($request); + + $this->assertEquals(403, $response->getStatusCode()); + $content = json_decode($response->getContent(), true); + $this->assertArrayHasKey('error', $content); + $this->assertEquals('missing_workspace_context', $content['error']); + } + + // ───────────────────────────────────────────────────────────────────────── + // WorkspaceScope Strict Mode Tests + // ───────────────────────────────────────────────────────────────────────── + + public function test_workspace_scope_throws_in_strict_mode_without_context(): void + { + WorkspaceScope::enableStrictMode(); + + // Ensure no workspace context + request()->attributes->remove('workspace_model'); + + $this->expectException(MissingWorkspaceContextException::class); + $this->expectExceptionMessage('scope'); + + // This should throw because no workspace context is available + Account::query()->get(); + } + + public function test_workspace_scope_works_with_valid_context(): void + { + $this->actingAs($this->user); + + // Create an account for this workspace + WorkspaceScope::withoutStrictMode(function () { + Account::factory()->create(['workspace_id' => $this->workspace->id]); + }); + + // Set workspace context + request()->attributes->set('workspace_model', $this->workspace); + + // Should not throw + $accounts = Account::query()->get(); + + $this->assertCount(1, $accounts); + } + + public function test_workspace_scope_strict_mode_can_be_disabled(): void + { + // Ensure no workspace context + request()->attributes->remove('workspace_model'); + + WorkspaceScope::disableStrictMode(); + + // Should not throw, but return empty result + $accounts = Account::query()->get(); + + $this->assertCount(0, $accounts); + + // Re-enable for other tests + WorkspaceScope::enableStrictMode(); + } + + public function test_without_strict_mode_callback_restores_state(): void + { + WorkspaceScope::enableStrictMode(); + $this->assertTrue(WorkspaceScope::isStrictModeEnabled()); + + WorkspaceScope::withoutStrictMode(function () { + $this->assertFalse(WorkspaceScope::isStrictModeEnabled()); + }); + + $this->assertTrue(WorkspaceScope::isStrictModeEnabled()); + } + + public function test_for_workspace_macro_bypasses_strict_mode(): void + { + // Ensure no current workspace context + request()->attributes->remove('workspace_model'); + + // Create data + WorkspaceScope::withoutStrictMode(function () { + Account::factory()->create(['workspace_id' => $this->workspace->id]); + }); + + // forWorkspace should work even without global context + $accounts = Account::query()->forWorkspace($this->workspace)->get(); + + $this->assertCount(1, $accounts); + } + + public function test_across_workspaces_macro_bypasses_strict_mode(): void + { + // Ensure no current workspace context + request()->attributes->remove('workspace_model'); + + // Create data in multiple workspaces + $workspace2 = Workspace::factory()->create(); + + WorkspaceScope::withoutStrictMode(function () use ($workspace2) { + Account::factory()->create(['workspace_id' => $this->workspace->id]); + Account::factory()->create(['workspace_id' => $workspace2->id]); + }); + + // acrossWorkspaces should work without context + $accounts = Account::query()->acrossWorkspaces()->get(); + + $this->assertCount(2, $accounts); + } + + // ───────────────────────────────────────────────────────────────────────── + // BelongsToWorkspace Trait Tests + // ───────────────────────────────────────────────────────────────────────── + + public function test_creating_model_without_workspace_throws_in_strict_mode(): void + { + // Ensure no workspace context + request()->attributes->remove('workspace_model'); + WorkspaceScope::enableStrictMode(); + + $this->expectException(MissingWorkspaceContextException::class); + $this->expectExceptionMessage('create'); + + Account::create([ + 'uuid' => \Illuminate\Support\Str::uuid(), + 'provider' => 'twitter', + 'provider_id' => '12345', + 'name' => 'Test Account', + 'credentials' => collect(['access_token' => 'test-token']), + ]); + } + + public function test_creating_model_with_explicit_workspace_id_succeeds(): void + { + // Ensure no workspace context + request()->attributes->remove('workspace_model'); + + // Should succeed because workspace_id is explicitly provided + $account = Account::create([ + 'uuid' => \Illuminate\Support\Str::uuid(), + 'workspace_id' => $this->workspace->id, + 'provider' => 'twitter', + 'provider_id' => '12345', + 'name' => 'Test Account', + 'credentials' => collect(['access_token' => 'test-token']), + ]); + + $this->assertEquals($this->workspace->id, $account->workspace_id); + } + + public function test_creating_model_with_workspace_context_auto_assigns(): void + { + $this->actingAs($this->user); + request()->attributes->set('workspace_model', $this->workspace); + + $account = Account::create([ + 'uuid' => \Illuminate\Support\Str::uuid(), + 'provider' => 'twitter', + 'provider_id' => '12345', + 'name' => 'Test Account', + 'credentials' => collect(['access_token' => 'test-token']), + ]); + + $this->assertEquals($this->workspace->id, $account->workspace_id); + } + + public function test_owned_by_current_workspace_throws_without_context(): void + { + // Ensure no workspace context + request()->attributes->remove('workspace_model'); + WorkspaceScope::enableStrictMode(); + + $this->expectException(MissingWorkspaceContextException::class); + + Account::ownedByCurrentWorkspace()->get(); + } + + public function test_owned_by_current_workspace_cached_throws_without_context(): void + { + // Ensure no workspace context + request()->attributes->remove('workspace_model'); + WorkspaceScope::enableStrictMode(); + + $this->expectException(MissingWorkspaceContextException::class); + + Account::ownedByCurrentWorkspaceCached(); + } + + // ───────────────────────────────────────────────────────────────────────── + // RequireWorkspaceContext Middleware Tests + // ───────────────────────────────────────────────────────────────────────── + + public function test_middleware_throws_without_workspace_context(): void + { + $middleware = new RequireWorkspaceContext; + $request = Request::create('/test', 'GET'); + + $this->expectException(MissingWorkspaceContextException::class); + + $middleware->handle($request, fn () => response('OK')); + } + + public function test_middleware_passes_with_workspace_model_attribute(): void + { + $middleware = new RequireWorkspaceContext; + $request = Request::create('/test', 'GET'); + $request->attributes->set('workspace_model', $this->workspace); + + $response = $middleware->handle($request, fn () => response('OK')); + + $this->assertEquals(200, $response->getStatusCode()); + } + + public function test_middleware_resolves_workspace_from_header(): void + { + $middleware = new RequireWorkspaceContext; + $request = Request::create('/test', 'GET'); + $request->headers->set('X-Workspace-ID', (string) $this->workspace->id); + + $response = $middleware->handle($request, fn () => response('OK')); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($this->workspace->id, $request->attributes->get('workspace_model')->id); + } + + public function test_middleware_resolves_workspace_from_query(): void + { + $middleware = new RequireWorkspaceContext; + $request = Request::create('/test?workspace='.$this->workspace->slug, 'GET'); + + $response = $middleware->handle($request, fn () => response('OK')); + + $this->assertEquals(200, $response->getStatusCode()); + } + + public function test_middleware_validates_user_access_when_requested(): void + { + $middleware = new RequireWorkspaceContext; + + // Create another workspace the user doesn't have access to + $otherWorkspace = Workspace::factory()->create(['name' => 'Other Workspace']); + + $this->actingAs($this->user); + $request = Request::create('/test', 'GET'); + $request->setUserResolver(fn () => $this->user); + $request->attributes->set('workspace_model', $otherWorkspace); + + $this->expectException(MissingWorkspaceContextException::class); + $this->expectExceptionMessage('do not have access'); + + $middleware->handle($request, fn () => response('OK'), 'validate'); + } + + public function test_middleware_allows_access_to_user_workspace(): void + { + $middleware = new RequireWorkspaceContext; + + $this->actingAs($this->user); + $request = Request::create('/test', 'GET'); + $request->setUserResolver(fn () => $this->user); + $request->attributes->set('workspace_model', $this->workspace); + + $response = $middleware->handle($request, fn () => response('OK'), 'validate'); + + $this->assertEquals(200, $response->getStatusCode()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Cross-Tenant Isolation Tests + // ───────────────────────────────────────────────────────────────────────── + + public function test_cannot_query_other_workspace_data_with_scoped_query(): void + { + $workspace2 = Workspace::factory()->create(['name' => 'Workspace 2']); + + // Create accounts in both workspaces (bypass strict mode for setup) + WorkspaceScope::withoutStrictMode(function () use ($workspace2) { + Account::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Account 1']); + Account::factory()->create(['workspace_id' => $workspace2->id, 'name' => 'Account 2']); + }); + + // Set context to workspace 1 + request()->attributes->set('workspace_model', $this->workspace); + + // Should only see workspace 1's accounts + $accounts = Account::query()->get(); + $this->assertCount(1, $accounts); + $this->assertEquals('Account 1', $accounts->first()->name); + } + + public function test_model_belongs_to_workspace_check_works(): void + { + $workspace2 = Workspace::factory()->create(); + + $account = null; + WorkspaceScope::withoutStrictMode(function () use (&$account) { + $account = Account::factory()->create(['workspace_id' => $this->workspace->id]); + }); + + $this->assertTrue($account->belongsToWorkspace($this->workspace)); + $this->assertTrue($account->belongsToWorkspace($this->workspace->id)); + $this->assertFalse($account->belongsToWorkspace($workspace2)); + $this->assertFalse($account->belongsToWorkspace($workspace2->id)); + } + + public function test_model_belongs_to_current_workspace_check_works(): void + { + $workspace2 = Workspace::factory()->create(); + + $account1 = null; + $account2 = null; + WorkspaceScope::withoutStrictMode(function () use (&$account1, &$account2, $workspace2) { + $account1 = Account::factory()->create(['workspace_id' => $this->workspace->id]); + $account2 = Account::factory()->create(['workspace_id' => $workspace2->id]); + }); + + // Set current workspace + request()->attributes->set('workspace_model', $this->workspace); + + $this->assertTrue($account1->belongsToCurrentWorkspace()); + $this->assertFalse($account2->belongsToCurrentWorkspace()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Model Opt-Out Tests + // ───────────────────────────────────────────────────────────────────────── + + public function test_model_can_opt_out_of_strict_workspace_context(): void + { + // Create a test model class that opts out + $model = new class extends Model + { + use BelongsToWorkspace; + + protected $table = 'test_models'; + + protected bool $workspaceContextRequired = false; + }; + + // Ensure no workspace context + request()->attributes->remove('workspace_model'); + WorkspaceScope::enableStrictMode(); + + // Should not throw because model opted out + $this->assertFalse($model->requiresWorkspaceContext()); + } +} diff --git a/tests/Feature/WorkspaceTenancyTest.php b/tests/Feature/WorkspaceTenancyTest.php new file mode 100644 index 0000000..f4132a0 --- /dev/null +++ b/tests/Feature/WorkspaceTenancyTest.php @@ -0,0 +1,165 @@ +userA = User::factory()->create(['name' => 'User A']); + $this->userB = User::factory()->create(['name' => 'User B']); + + $this->workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); + $this->workspaceB = Workspace::factory()->create(['name' => 'Workspace B']); + + // Attach users to their workspaces + $this->userA->hostWorkspaces()->attach($this->workspaceA, ['role' => 'owner', 'is_default' => true]); + $this->userB->hostWorkspaces()->attach($this->workspaceB, ['role' => 'owner', 'is_default' => true]); + } + + public function test_workspace_has_relationship_methods_for_all_services() + { + $workspace = Workspace::factory()->create(); + + // Test that all relationship methods exist and return correct type + $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $workspace->socialAccounts()); + $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $workspace->socialPosts()); + $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $workspace->analyticsSites()); + $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $workspace->trustWidgets()); + $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $workspace->notificationSites()); + $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $workspace->pushCampaigns()); + // NOTE: bioPages relationship has been moved to Host UK app's Mod\Bio module + } + + public function test_workspace_current_resolves_from_authenticated_user() + { + $this->actingAs($this->userA); + + $current = Workspace::current(); + + $this->assertNotNull($current); + $this->assertEquals($this->workspaceA->id, $current->id); + } + + public function test_workspace_scoping_isolates_data_between_workspaces() + { + // Create social accounts for each workspace + $accountA = Account::factory()->create([ + 'workspace_id' => $this->workspaceA->id, + 'name' => 'Account A', + ]); + + $accountB = Account::factory()->create([ + 'workspace_id' => $this->workspaceB->id, + 'name' => 'Account B', + ]); + + // User A should only see their workspace's account + $this->actingAs($this->userA); + $accountsForUserA = Account::ownedByCurrentWorkspace()->get(); + $this->assertCount(1, $accountsForUserA); + $this->assertEquals('Account A', $accountsForUserA->first()->name); + + // User B should only see their workspace's account + $this->actingAs($this->userB); + $accountsForUserB = Account::ownedByCurrentWorkspace()->get(); + $this->assertCount(1, $accountsForUserB); + $this->assertEquals('Account B', $accountsForUserB->first()->name); + } + + public function test_workspace_relationships_return_correct_models() + { + // Create various resources for workspace A + Account::factory()->create(['workspace_id' => $this->workspaceA->id]); + Account::factory()->create(['workspace_id' => $this->workspaceA->id]); + Website::factory()->create(['workspace_id' => $this->workspaceA->id]); + + // Create some for workspace B (should not appear) + Account::factory()->create(['workspace_id' => $this->workspaceB->id]); + + $this->assertEquals(2, $this->workspaceA->socialAccounts()->count()); + $this->assertEquals(1, $this->workspaceA->analyticsSites()->count()); + + // Workspace B should have different counts + $this->assertEquals(1, $this->workspaceB->socialAccounts()->count()); + } + + public function test_models_with_workspace_trait_auto_assign_workspace_on_create() + { + $this->actingAs($this->userA); + + // When creating a model with BelongsToWorkspace trait, + // it should auto-assign the current user's workspace + $account = Account::create([ + 'uuid' => \Illuminate\Support\Str::uuid(), + 'provider' => 'twitter', + 'provider_id' => '12345', + 'name' => 'Test Account', + 'credentials' => collect(['access_token' => 'test-token']), + ]); + + $this->assertEquals($this->workspaceA->id, $account->workspace_id); + } + + public function test_workspace_scope_prevents_cross_workspace_access() + { + $accountA = Account::factory()->create([ + 'workspace_id' => $this->workspaceA->id, + 'uuid' => 'uuid-a', + ]); + + $accountB = Account::factory()->create([ + 'workspace_id' => $this->workspaceB->id, + 'uuid' => 'uuid-b', + ]); + + $this->actingAs($this->userA); + + // User A should be able to find their account + $found = Account::ownedByCurrentWorkspace()->where('uuid', 'uuid-a')->first(); + $this->assertNotNull($found); + + // User A should NOT be able to find User B's account via scoped query + $notFound = Account::ownedByCurrentWorkspace()->where('uuid', 'uuid-b')->first(); + $this->assertNull($notFound); + + // But should be able to find it if scope is explicitly bypassed + $foundWithoutScope = Account::withoutGlobalScopes()->where('uuid', 'uuid-b')->first(); + $this->assertNotNull($foundWithoutScope); + } + + public function test_belongs_to_workspace_method_checks_ownership() + { + $accountA = Account::factory()->create(['workspace_id' => $this->workspaceA->id]); + $accountB = Account::factory()->create(['workspace_id' => $this->workspaceB->id]); + + $this->assertTrue($accountA->belongsToWorkspace($this->workspaceA)); + $this->assertFalse($accountA->belongsToWorkspace($this->workspaceB)); + + $this->assertTrue($accountB->belongsToWorkspace($this->workspaceB)); + $this->assertFalse($accountB->belongsToWorkspace($this->workspaceA)); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php deleted file mode 100644 index fe1ffc2..0000000 --- a/tests/TestCase.php +++ /dev/null @@ -1,10 +0,0 @@ -