monorepo sepration
This commit is contained in:
parent
496551ee53
commit
bc9ffd74d3
170 changed files with 26922 additions and 587 deletions
76
.env.example
76
.env.example
|
|
@ -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=
|
||||
115
CLAUDE.md
115
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).
|
||||
|
|
|
|||
182
README.md
182
README.md
|
|
@ -1,137 +1,131 @@
|
|||
# Core PHP Framework Project
|
||||
# Core Tenant
|
||||
|
||||
[](https://github.com/host-uk/core-template/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/host-uk/core-template)
|
||||
[](https://packagist.org/packages/host-uk/core-template)
|
||||
[](https://laravel.com)
|
||||
[](https://github.com/host-uk/core-tenant/actions/workflows/ci.yml)
|
||||
[](https://packagist.org/packages/host-uk/core-tenant)
|
||||
[](https://laravel.com)
|
||||
[](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
|
||||
<?php
|
||||
use Core\Mod\Tenant\Services\WorkspaceManager;
|
||||
use Core\Mod\Tenant\Services\WorkspaceService;
|
||||
|
||||
namespace App\Mod\Blog;
|
||||
// Get current workspace
|
||||
$workspace = app(WorkspaceManager::class)->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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
15
artisan
15
artisan
|
|
@ -1,15 +0,0 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
$status = (require_once __DIR__.'/bootstrap/app.php')
|
||||
->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->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();
|
||||
2
bootstrap/cache/.gitignore
vendored
2
bootstrap/cache/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
|
|
@ -1,68 +1,46 @@
|
|||
{
|
||||
"name": "host-uk/core-template",
|
||||
"type": "project",
|
||||
"description": "Core PHP Framework - Project Template",
|
||||
"keywords": ["laravel", "core-php", "modular", "framework", "template"],
|
||||
"name": "host-uk/core-tenant",
|
||||
"description": "Multi-tenancy module for Core PHP Framework - users, workspaces, entitlements",
|
||||
"keywords": ["laravel", "core-php", "tenancy", "multi-tenant", "workspace", "entitlements"],
|
||||
"license": "EUPL-1.2",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Host UK",
|
||||
"email": "support@host.uk.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10",
|
||||
"livewire/flux": "^2.0",
|
||||
"livewire/livewire": "^3.0",
|
||||
"host-uk/core": "dev-main",
|
||||
"host-uk/core-admin": "dev-main",
|
||||
"host-uk/core-api": "dev-main",
|
||||
"host-uk/core-mcp": "dev-main"
|
||||
"laravel/framework": "^11.0|^12.0",
|
||||
"host-uk/core": "^1.0|dev-main"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2",
|
||||
"laravel/pint": "^1.18",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^3.0",
|
||||
"pestphp/pest-plugin-laravel": "^3.0"
|
||||
"orchestra/testbench": "^9.0|^10.0",
|
||||
"phpunit/phpunit": "^11.5"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
"Core\\Mod\\Tenant\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
"Core\\Mod\\Tenant\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/host-uk/core-php.git"
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
]
|
||||
"test": "vendor/bin/phpunit",
|
||||
"pint": "vendor/bin/pint"
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
"providers": [
|
||||
"Core\\Mod\\Tenant\\Boot"
|
||||
]
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Core PHP Framework Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'module_paths' => [
|
||||
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'),
|
||||
],
|
||||
];
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Core modules handle their own seeding
|
||||
}
|
||||
}
|
||||
16
package.json
16
package.json
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.7.4",
|
||||
"laravel-vite-plugin": "^2.1.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
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]
|
||||
</IfModule>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
(require_once __DIR__.'/../bootstrap/app.php')
|
||||
->handleRequest(Request::capture());
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
User-agent: *
|
||||
Disallow:
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -1 +0,0 @@
|
|||
import './bootstrap';
|
||||
3
resources/js/bootstrap.js
vendored
3
resources/js/bootstrap.js
vendored
|
|
@ -1,3 +0,0 @@
|
|||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Core PHP Framework</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.version {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: 1px solid #667eea;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
a:hover {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Core PHP Framework</h1>
|
||||
<p class="version">Laravel {{ Illuminate\Foundation\Application::VERSION }} | PHP {{ PHP_VERSION }}</p>
|
||||
<div class="links">
|
||||
<a href="https://github.com/host-uk/core-php">Documentation</a>
|
||||
<a href="/admin">Admin Panel</a>
|
||||
<a href="/api/docs">API Docs</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// API routes are registered via Core modules
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<?php
|
||||
|
||||
// Console commands are registered via Core modules
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
});
|
||||
173
src/Boot.php
Normal file
173
src/Boot.php
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant;
|
||||
|
||||
use Core\Events\AdminPanelBooting;
|
||||
use Core\Events\ApiRoutesRegistering;
|
||||
use Core\Events\ConsoleBooting;
|
||||
use Core\Events\WebRoutesRegistering;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
/**
|
||||
* Tenant Module Boot.
|
||||
*
|
||||
* Core multi-tenancy module handling:
|
||||
* - Users and authentication
|
||||
* - Workspaces (the tenant boundary)
|
||||
* - Account management (deletion, settings)
|
||||
* - Entitlements (feature access, packages, usage)
|
||||
* - Referrals
|
||||
*/
|
||||
class Boot extends ServiceProvider
|
||||
{
|
||||
protected string $moduleName = 'tenant';
|
||||
|
||||
/**
|
||||
* Events this module listens to for lazy loading.
|
||||
*
|
||||
* @var array<class-string, string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
247
src/Concerns/BelongsToNamespace.php
Normal file
247
src/Concerns/BelongsToNamespace.php
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Concerns;
|
||||
|
||||
use Core\Mod\Tenant\Models\Namespace_;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Trait for models that belong to a namespace.
|
||||
*
|
||||
* Provides namespace relationship, scoping, and namespace-scoped caching.
|
||||
* This replaces dual workspace_id/user_id ownership with a single namespace_id.
|
||||
*
|
||||
* Usage:
|
||||
* class Page extends Model {
|
||||
* use BelongsToNamespace;
|
||||
* }
|
||||
*
|
||||
* // Get cached collection for current namespace
|
||||
* $pages = Page::ownedByCurrentNamespaceCached();
|
||||
*
|
||||
* // Get query scoped to current namespace
|
||||
* $pages = Page::ownedByCurrentNamespace()->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);
|
||||
}
|
||||
}
|
||||
349
src/Concerns/BelongsToWorkspace.php
Normal file
349
src/Concerns/BelongsToWorkspace.php
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Concerns;
|
||||
|
||||
use Core\Mod\Tenant\Exceptions\MissingWorkspaceContextException;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Scopes\WorkspaceScope;
|
||||
use Core\Mod\Tenant\Services\WorkspaceCacheManager;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Trait for models that belong to a workspace.
|
||||
*
|
||||
* SECURITY: This trait enforces workspace isolation by:
|
||||
* 1. Auto-assigning workspace_id on create (throws if no context)
|
||||
* 2. Scoping queries to current workspace
|
||||
* 3. Providing workspace-scoped caching with auto-invalidation
|
||||
*
|
||||
* Usage:
|
||||
* class Account extends Model {
|
||||
* use BelongsToWorkspace;
|
||||
* }
|
||||
*
|
||||
* // Get cached collection for current workspace
|
||||
* $accounts = Account::ownedByCurrentWorkspaceCached();
|
||||
*
|
||||
* // Get query scoped to current workspace
|
||||
* $accounts = Account::ownedByCurrentWorkspace()->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);
|
||||
}
|
||||
}
|
||||
272
src/Concerns/HasWorkspaceCache.php
Normal file
272
src/Concerns/HasWorkspaceCache.php
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Concerns;
|
||||
|
||||
use Closure;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Services\WorkspaceCacheManager;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Trait for models that need custom workspace-scoped caching.
|
||||
*
|
||||
* While BelongsToWorkspace provides basic caching for the default
|
||||
* ownedByCurrentWorkspace query, this trait provides a more flexible API
|
||||
* for custom caching needs within a workspace context.
|
||||
*
|
||||
* Usage:
|
||||
* 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 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);
|
||||
}
|
||||
}
|
||||
250
src/Concerns/TwoFactorAuthenticatable.php
Normal file
250
src/Concerns/TwoFactorAuthenticatable.php
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Concerns;
|
||||
|
||||
use Core\Mod\Tenant\Contracts\TwoFactorAuthenticationProvider;
|
||||
use Core\Mod\Tenant\Models\UserTwoFactorAuth;
|
||||
use Core\Mod\Tenant\Services\TotpService;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
/**
|
||||
* Trait for two-factor authentication support.
|
||||
*
|
||||
* Provides TOTP-based 2FA using the TotpService.
|
||||
*/
|
||||
trait TwoFactorAuthenticatable
|
||||
{
|
||||
/**
|
||||
* Get the user's two-factor authentication record.
|
||||
*/
|
||||
public function twoFactorAuth(): HasOne
|
||||
{
|
||||
return $this->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);
|
||||
}
|
||||
}
|
||||
261
src/Console/Commands/CheckUsageAlerts.php
Normal file
261
src/Console/Commands/CheckUsageAlerts.php
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Console\Commands;
|
||||
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Services\UsageAlertService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Check workspaces for usage alerts and send notifications.
|
||||
*
|
||||
* This command should be scheduled to run periodically (e.g., hourly)
|
||||
* to monitor entitlement usage and alert users when approaching limits.
|
||||
*/
|
||||
class CheckUsageAlerts extends Command
|
||||
{
|
||||
protected $signature = 'tenant:check-usage-alerts
|
||||
{--workspace= : Check a specific workspace by ID or slug}
|
||||
{--dry-run : Show what would be sent without actually sending}
|
||||
{--verbose : Show detailed output}';
|
||||
|
||||
protected $description = 'Check workspaces for usage alerts and send notifications when approaching limits';
|
||||
|
||||
public function __construct(
|
||||
protected UsageAlertService $alertService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->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 '<fg=red>At Limit</>';
|
||||
}
|
||||
|
||||
if ($status['percentage'] >= 90) {
|
||||
return '<fg=yellow>Critical</>';
|
||||
}
|
||||
|
||||
if ($status['near_limit']) {
|
||||
return '<fg=yellow>Warning</>';
|
||||
}
|
||||
|
||||
return '<fg=green>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;
|
||||
}
|
||||
}
|
||||
82
src/Console/Commands/ProcessAccountDeletions.php
Normal file
82
src/Console/Commands/ProcessAccountDeletions.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Console\Commands;
|
||||
|
||||
use Core\Mod\Tenant\Models\AccountDeletionRequest;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessAccountDeletions extends Command
|
||||
{
|
||||
protected $signature = 'accounts:process-deletions';
|
||||
|
||||
protected $description = 'Process pending account deletions that have passed their 7-day expiry';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$pendingDeletions = AccountDeletionRequest::pendingAutoDelete()->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;
|
||||
}
|
||||
}
|
||||
56
src/Console/Commands/RefreshUserStats.php
Normal file
56
src/Console/Commands/RefreshUserStats.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Console\Commands;
|
||||
|
||||
use Core\Mod\Tenant\Jobs\ComputeUserStats;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RefreshUserStats extends Command
|
||||
{
|
||||
protected $signature = 'users:refresh-stats {--user= : Specific user ID to refresh}';
|
||||
|
||||
protected $description = 'Refresh cached stats for users';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ($userId = $this->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!');
|
||||
}
|
||||
}
|
||||
411
src/Console/Commands/ResetBillingCycles.php
Normal file
411
src/Console/Commands/ResetBillingCycles.php
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Console\Commands;
|
||||
|
||||
use Core\Mod\Tenant\Models\Boost;
|
||||
use Core\Mod\Tenant\Models\EntitlementLog;
|
||||
use Core\Mod\Tenant\Models\UsageRecord;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Notifications\BoostExpiredNotification;
|
||||
use Core\Mod\Tenant\Services\EntitlementService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Reset billing cycle counters and expire cycle-bound boosts.
|
||||
*
|
||||
* This command should be scheduled to run daily to:
|
||||
* - Reset usage counters at billing period start
|
||||
* - Expire temporary boosts at period end
|
||||
* - Notify users when boosts expire
|
||||
* - Log all actions for audit trail
|
||||
*/
|
||||
class ResetBillingCycles extends Command
|
||||
{
|
||||
protected $signature = 'tenant:reset-billing-cycles
|
||||
{--workspace= : Process a specific workspace by ID or slug}
|
||||
{--dry-run : Show what would happen without making changes}
|
||||
{--verbose : Show detailed output}';
|
||||
|
||||
protected $description = 'Reset billing cycle usage counters and expire cycle-bound boosts';
|
||||
|
||||
protected int $boostsExpired = 0;
|
||||
|
||||
protected int $usageCountersReset = 0;
|
||||
|
||||
protected int $notificationsSent = 0;
|
||||
|
||||
protected int $workspacesProcessed = 0;
|
||||
|
||||
public function __construct(
|
||||
protected EntitlementService $entitlementService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/Contracts/EntitlementWebhookEvent.php
Normal file
37
src/Contracts/EntitlementWebhookEvent.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Contracts;
|
||||
|
||||
/**
|
||||
* Contract for entitlement webhook events.
|
||||
*
|
||||
* Defines structure for webhook event types that can be
|
||||
* dispatched to external endpoints when entitlement-related
|
||||
* events occur (usage alerts, package changes, boost expiry).
|
||||
*/
|
||||
interface EntitlementWebhookEvent
|
||||
{
|
||||
/**
|
||||
* Get the event name/identifier (e.g., 'limit_warning', 'package_changed').
|
||||
*/
|
||||
public static function name(): string;
|
||||
|
||||
/**
|
||||
* Get the localised event name for display.
|
||||
*/
|
||||
public static function nameLocalised(): string;
|
||||
|
||||
/**
|
||||
* Get the event payload data.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function payload(): array;
|
||||
|
||||
/**
|
||||
* Get a human-readable message for this event.
|
||||
*/
|
||||
public function message(): string;
|
||||
}
|
||||
36
src/Contracts/TwoFactorAuthenticationProvider.php
Normal file
36
src/Contracts/TwoFactorAuthenticationProvider.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Contracts;
|
||||
|
||||
/**
|
||||
* Contract for two-factor authentication providers.
|
||||
*
|
||||
* Handles TOTP (Time-based One-Time Password) generation and verification
|
||||
* for user accounts. Typically implemented using libraries like Google Authenticator.
|
||||
*/
|
||||
interface TwoFactorAuthenticationProvider
|
||||
{
|
||||
/**
|
||||
* Generate a new secret key for TOTP.
|
||||
*/
|
||||
public function generateSecretKey(): string;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Verify a TOTP code against the secret.
|
||||
*
|
||||
* @param string $secret TOTP secret key
|
||||
* @param string $code User-provided 6-digit code
|
||||
*/
|
||||
public function verify(string $secret, string $code): bool;
|
||||
}
|
||||
255
src/Controllers/Api/EntitlementWebhookController.php
Normal file
255
src/Controllers/Api/EntitlementWebhookController.php
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Controllers\Api;
|
||||
|
||||
use Core\Mod\Tenant\Models\EntitlementWebhook;
|
||||
use Core\Mod\Tenant\Models\EntitlementWebhookDelivery;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Services\EntitlementWebhookService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* API controller for entitlement webhook management.
|
||||
*
|
||||
* Provides CRUD operations for webhooks and delivery history.
|
||||
*/
|
||||
class EntitlementWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected EntitlementWebhookService $webhookService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List webhooks for the current workspace.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$workspace = $this->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');
|
||||
}
|
||||
}
|
||||
}
|
||||
493
src/Controllers/EntitlementApiController.php
Normal file
493
src/Controllers/EntitlementApiController.php
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Controllers;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Mod\Tenant\Models\EntitlementLog;
|
||||
use Mod\Tenant\Models\Package;
|
||||
use Mod\Tenant\Models\User;
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
use Mod\Tenant\Models\WorkspacePackage;
|
||||
use Mod\Tenant\Services\EntitlementService;
|
||||
|
||||
class EntitlementApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected EntitlementService $entitlements
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new entitlement for a workspace.
|
||||
*
|
||||
* Expected payload:
|
||||
* - email: string (client email to find/create user)
|
||||
* - name: string (client name)
|
||||
* - product_code: string (package code)
|
||||
* - billing_cycle_anchor: string|null (ISO date)
|
||||
* - expires_at: string|null (ISO date)
|
||||
* - blesta_service_id: string|null
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->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);
|
||||
}
|
||||
}
|
||||
138
src/Controllers/ReferralController.php
Normal file
138
src/Controllers/ReferralController.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Controllers;
|
||||
|
||||
use Core\Helpers\PrivacyHelper;
|
||||
use Core\Mod\Trees\Models\TreePlanting;
|
||||
use Core\Mod\Trees\Models\TreePlantingStats;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
|
||||
/**
|
||||
* Handles agent referral tracking for the Trees for Agents programme.
|
||||
*
|
||||
* When an AI agent refers a user via /ref/{provider}/{model?}, we:
|
||||
* 1. Store the referral in session
|
||||
* 2. Set a 30-day cookie as backup
|
||||
* 3. Redirect to registration with ref=agent parameter
|
||||
*
|
||||
* On signup, PlantTreeForAgentReferral listener plants a tree for the referrer.
|
||||
*/
|
||||
class ReferralController extends \Core\Front\Controller
|
||||
{
|
||||
/**
|
||||
* Cookie name for agent referral tracking.
|
||||
*/
|
||||
public const REFERRAL_COOKIE = 'agent_referral';
|
||||
|
||||
/**
|
||||
* Session key for agent referral.
|
||||
*/
|
||||
public const REFERRAL_SESSION = 'agent_referral';
|
||||
|
||||
/**
|
||||
* Cookie lifetime in minutes (30 days).
|
||||
*/
|
||||
public const COOKIE_LIFETIME = 60 * 24 * 30;
|
||||
|
||||
/**
|
||||
* Track an agent referral and redirect to registration.
|
||||
*
|
||||
* @param string $provider The AI provider (anthropic, openai, etc.)
|
||||
* @param string|null $model Optional model identifier (claude-opus, gpt-4, etc.)
|
||||
*/
|
||||
public function track(Request $request, string $provider, ?string $model = null): RedirectResponse
|
||||
{
|
||||
// Validate provider against allowlist
|
||||
if (! TreePlanting::isValidProvider($provider)) {
|
||||
// Invalid provider — redirect to pricing without referral
|
||||
return redirect()->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));
|
||||
}
|
||||
}
|
||||
277
src/Controllers/WorkspaceController.php
Normal file
277
src/Controllers/WorkspaceController.php
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Controllers;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Mod\Api\Controllers\Concerns\HasApiResponses;
|
||||
use Mod\Api\Controllers\Concerns\ResolvesWorkspace;
|
||||
use Mod\Api\Resources\PaginatedCollection;
|
||||
use Mod\Api\Resources\WorkspaceResource;
|
||||
use Mod\Tenant\Models\User;
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* Workspace API controller.
|
||||
*
|
||||
* Provides CRUD operations for workspaces via REST API.
|
||||
* Supports both API key and session authentication.
|
||||
*/
|
||||
class WorkspaceController extends Controller
|
||||
{
|
||||
use HasApiResponses;
|
||||
use ResolvesWorkspace;
|
||||
|
||||
/**
|
||||
* List all workspaces the user has access to.
|
||||
*
|
||||
* GET /api/v1/workspaces
|
||||
*/
|
||||
public function index(Request $request): PaginatedCollection|JsonResponse
|
||||
{
|
||||
$user = $request->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);
|
||||
}
|
||||
}
|
||||
68
src/Controllers/WorkspaceInvitationController.php
Normal file
68
src/Controllers/WorkspaceInvitationController.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Controllers;
|
||||
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Models\WorkspaceInvitation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Handles workspace invitation acceptance.
|
||||
*
|
||||
* Users receive an email with a unique token link. When clicked:
|
||||
* - If authenticated: Accept invitation and redirect to workspace
|
||||
* - If not authenticated: Redirect to login with return URL
|
||||
*/
|
||||
class WorkspaceInvitationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle invitation acceptance.
|
||||
*/
|
||||
public function __invoke(Request $request, string $token): RedirectResponse|View
|
||||
{
|
||||
$invitation = WorkspaceInvitation::findByToken($token);
|
||||
|
||||
// Invalid token
|
||||
if (! $invitation) {
|
||||
return redirect()->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}.");
|
||||
}
|
||||
}
|
||||
73
src/Database/Factories/UserFactory.php
Normal file
73
src/Database/Factories/UserFactory.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\Core\Mod\Tenant\Models\User>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
87
src/Database/Factories/UserTokenFactory.php
Normal file
87
src/Database/Factories/UserTokenFactory.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Database\Factories;
|
||||
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\UserToken;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Factory for generating UserToken test instances.
|
||||
*
|
||||
* @extends Factory<UserToken>
|
||||
*/
|
||||
class UserTokenFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var class-string<UserToken>
|
||||
*/
|
||||
protected $model = UserToken::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
59
src/Database/Factories/WaitlistEntryFactory.php
Normal file
59
src/Database/Factories/WaitlistEntryFactory.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Database\Factories;
|
||||
|
||||
use Core\Mod\Tenant\Models\WaitlistEntry;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\Core\Mod\Tenant\Models\WaitlistEntry>
|
||||
*/
|
||||
class WaitlistEntryFactory extends Factory
|
||||
{
|
||||
protected $model = WaitlistEntry::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
81
src/Database/Factories/WorkspaceFactory.php
Normal file
81
src/Database/Factories/WorkspaceFactory.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Database\Factories;
|
||||
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\Core\Mod\Tenant\Models\Workspace>
|
||||
*/
|
||||
class WorkspaceFactory extends Factory
|
||||
{
|
||||
protected $model = Workspace::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
75
src/Database/Factories/WorkspaceInvitationFactory.php
Normal file
75
src/Database/Factories/WorkspaceInvitationFactory.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Database\Factories;
|
||||
|
||||
use Core\Mod\Tenant\Models\WorkspaceInvitation;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\Core\Mod\Tenant\Models\WorkspaceInvitation>
|
||||
*/
|
||||
class WorkspaceInvitationFactory extends Factory
|
||||
{
|
||||
protected $model = WorkspaceInvitation::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
170
src/Database/Seeders/DemoTestUserSeeder.php
Normal file
170
src/Database/Seeders/DemoTestUserSeeder.php
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Database\Seeders;
|
||||
|
||||
use Core\Mod\Tenant\Models\Package;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
/**
|
||||
* Creates a demo test user with known state for Playwright acceptance tests.
|
||||
*
|
||||
* This user has:
|
||||
* - Nyx tier (Lethean Network demo/test designation)
|
||||
* - Minimal settings and data
|
||||
* - Predictable credentials for automated testing
|
||||
*
|
||||
* Tier naming follows Lethean Network designations:
|
||||
* - Nyx: Demo/test accounts (goddess of night)
|
||||
* - Stygian: Standard users (River Styx)
|
||||
* - Apollo/Hades: Internal tiers (existing)
|
||||
*
|
||||
* Run with: php artisan db:seed --class=DemoTestUserSeeder
|
||||
*/
|
||||
class DemoTestUserSeeder extends Seeder
|
||||
{
|
||||
// Fixed credentials for test automation
|
||||
public const EMAIL = 'nyx@host.uk.com';
|
||||
|
||||
public const PASSWORD = 'nyx-test-2026';
|
||||
|
||||
public const WORKSPACE_SLUG = 'nyx-demo';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// Create or update Nyx demo user
|
||||
$user = User::updateOrCreate(
|
||||
['email' => 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
165
src/Database/Seeders/DemoWorkspaceSeeder.php
Normal file
165
src/Database/Seeders/DemoWorkspaceSeeder.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Database\Seeders;
|
||||
|
||||
use Core\Mod\Tenant\Models\Feature;
|
||||
use Core\Mod\Tenant\Models\Package;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Services\EntitlementService;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Creates demo workspaces for testing entitlement scenarios.
|
||||
*
|
||||
* Run: php artisan db:seed --class="Mod\Tenant\Database\Seeders\DemoWorkspaceSeeder"
|
||||
*/
|
||||
class DemoWorkspaceSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$entitlements = app(EntitlementService::class);
|
||||
|
||||
// Create demo packages if they don't exist
|
||||
$this->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}");
|
||||
}
|
||||
}
|
||||
901
src/Database/Seeders/FeatureSeeder.php
Normal file
901
src/Database/Seeders/FeatureSeeder.php
Normal file
|
|
@ -0,0 +1,901 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Database\Seeders;
|
||||
|
||||
use Core\Mod\Tenant\Models\Feature;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class FeatureSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
if (! Schema::hasTable('entitlement_features')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$features = [
|
||||
// Tier markers (boolean)
|
||||
[
|
||||
'code' => '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.');
|
||||
}
|
||||
}
|
||||
57
src/Database/Seeders/SystemWorkspaceSeeder.php
Normal file
57
src/Database/Seeders/SystemWorkspaceSeeder.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Database\Seeders;
|
||||
|
||||
use Core\Mod\Tenant\Models\Package;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Models\WorkspacePackage;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class SystemWorkspaceSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Assign all entitlements to system workspaces.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$hermes = Package::where('code', 'hermes')->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
183
src/Database/Seeders/WorkspaceSeeder.php
Normal file
183
src/Database/Seeders/WorkspaceSeeder.php
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Database\Seeders;
|
||||
|
||||
use Core\Mod\Tenant\Enums\UserTier;
|
||||
use Core\Mod\Tenant\Models\Package;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Models\WorkspacePackage;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class WorkspaceSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
if (! Schema::hasTable('workspaces') || ! Schema::hasTable('users')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Environment-aware domains: .test for local, .uk.com for production
|
||||
$isLocal = app()->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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
81
src/Enums/UserTier.php
Normal file
81
src/Enums/UserTier.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Enums;
|
||||
|
||||
enum UserTier: string
|
||||
{
|
||||
case FREE = 'free';
|
||||
case APOLLO = 'apollo'; // Standard paid tier
|
||||
case HADES = 'hades'; // Premium tier
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::FREE => '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());
|
||||
}
|
||||
}
|
||||
12
src/Enums/WebhookDeliveryStatus.php
Normal file
12
src/Enums/WebhookDeliveryStatus.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Enums;
|
||||
|
||||
enum WebhookDeliveryStatus: string
|
||||
{
|
||||
case PENDING = 'pending';
|
||||
case SUCCESS = 'success';
|
||||
case FAILED = 'failed';
|
||||
}
|
||||
58
src/Events/Webhook/BoostActivatedEvent.php
Normal file
58
src/Events/Webhook/BoostActivatedEvent.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Events\Webhook;
|
||||
|
||||
use Core\Mod\Tenant\Contracts\EntitlementWebhookEvent;
|
||||
use Core\Mod\Tenant\Models\Boost;
|
||||
use Core\Mod\Tenant\Models\Feature;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* Event fired when a boost is activated for a workspace.
|
||||
*/
|
||||
class BoostActivatedEvent implements EntitlementWebhookEvent
|
||||
{
|
||||
public function __construct(
|
||||
protected Workspace $workspace,
|
||||
protected Boost $boost,
|
||||
protected ?Feature $feature = null
|
||||
) {}
|
||||
|
||||
public static function name(): string
|
||||
{
|
||||
return 'boost_activated';
|
||||
}
|
||||
|
||||
public static function nameLocalised(): string
|
||||
{
|
||||
return __('Boost Activated');
|
||||
}
|
||||
|
||||
public function payload(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => $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}";
|
||||
}
|
||||
}
|
||||
58
src/Events/Webhook/BoostExpiredEvent.php
Normal file
58
src/Events/Webhook/BoostExpiredEvent.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Events\Webhook;
|
||||
|
||||
use Core\Mod\Tenant\Contracts\EntitlementWebhookEvent;
|
||||
use Core\Mod\Tenant\Models\Boost;
|
||||
use Core\Mod\Tenant\Models\Feature;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* Event fired when a boost expires for a workspace.
|
||||
*/
|
||||
class BoostExpiredEvent implements EntitlementWebhookEvent
|
||||
{
|
||||
public function __construct(
|
||||
protected Workspace $workspace,
|
||||
protected Boost $boost,
|
||||
protected ?Feature $feature = null
|
||||
) {}
|
||||
|
||||
public static function name(): string
|
||||
{
|
||||
return 'boost_expired';
|
||||
}
|
||||
|
||||
public static function nameLocalised(): string
|
||||
{
|
||||
return __('Boost Expired');
|
||||
}
|
||||
|
||||
public function payload(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => $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}";
|
||||
}
|
||||
}
|
||||
52
src/Events/Webhook/LimitReachedEvent.php
Normal file
52
src/Events/Webhook/LimitReachedEvent.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Events\Webhook;
|
||||
|
||||
use Core\Mod\Tenant\Contracts\EntitlementWebhookEvent;
|
||||
use Core\Mod\Tenant\Models\Feature;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* Event fired when workspace usage reaches 100% of the limit.
|
||||
*/
|
||||
class LimitReachedEvent implements EntitlementWebhookEvent
|
||||
{
|
||||
public function __construct(
|
||||
protected Workspace $workspace,
|
||||
protected Feature $feature,
|
||||
protected int $used,
|
||||
protected int $limit
|
||||
) {}
|
||||
|
||||
public static function name(): string
|
||||
{
|
||||
return 'limit_reached';
|
||||
}
|
||||
|
||||
public static function nameLocalised(): string
|
||||
{
|
||||
return __('Limit Reached');
|
||||
}
|
||||
|
||||
public function payload(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => $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}";
|
||||
}
|
||||
}
|
||||
56
src/Events/Webhook/LimitWarningEvent.php
Normal file
56
src/Events/Webhook/LimitWarningEvent.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Events\Webhook;
|
||||
|
||||
use Core\Mod\Tenant\Contracts\EntitlementWebhookEvent;
|
||||
use Core\Mod\Tenant\Models\Feature;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* Event fired when workspace usage reaches the warning threshold (80%).
|
||||
*/
|
||||
class LimitWarningEvent implements EntitlementWebhookEvent
|
||||
{
|
||||
public function __construct(
|
||||
protected Workspace $workspace,
|
||||
protected Feature $feature,
|
||||
protected int $used,
|
||||
protected int $limit,
|
||||
protected int $threshold = 80
|
||||
) {}
|
||||
|
||||
public static function name(): string
|
||||
{
|
||||
return 'limit_warning';
|
||||
}
|
||||
|
||||
public static function nameLocalised(): string
|
||||
{
|
||||
return __('Limit Warning');
|
||||
}
|
||||
|
||||
public function payload(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => $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}";
|
||||
}
|
||||
}
|
||||
67
src/Events/Webhook/PackageChangedEvent.php
Normal file
67
src/Events/Webhook/PackageChangedEvent.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Events\Webhook;
|
||||
|
||||
use Core\Mod\Tenant\Contracts\EntitlementWebhookEvent;
|
||||
use Core\Mod\Tenant\Models\Package;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* Event fired when a workspace's package changes (upgrade, downgrade, or new assignment).
|
||||
*/
|
||||
class PackageChangedEvent implements EntitlementWebhookEvent
|
||||
{
|
||||
public function __construct(
|
||||
protected Workspace $workspace,
|
||||
protected ?Package $previousPackage,
|
||||
protected Package $newPackage,
|
||||
protected string $changeType = 'changed' // 'added', 'changed', 'removed'
|
||||
) {}
|
||||
|
||||
public static function name(): string
|
||||
{
|
||||
return 'package_changed';
|
||||
}
|
||||
|
||||
public static function nameLocalised(): string
|
||||
{
|
||||
return __('Package Changed');
|
||||
}
|
||||
|
||||
public function payload(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => $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}";
|
||||
}
|
||||
}
|
||||
46
src/Exceptions/EntitlementException.php
Normal file
46
src/Exceptions/EntitlementException.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when an entitlement check fails.
|
||||
*/
|
||||
class EntitlementException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message = 'You have reached your limit for this feature.',
|
||||
public readonly ?string $featureCode = null,
|
||||
int $code = 403,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the feature code that was denied.
|
||||
*/
|
||||
public function getFeatureCode(): ?string
|
||||
{
|
||||
return $this->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());
|
||||
}
|
||||
}
|
||||
133
src/Exceptions/MissingWorkspaceContextException.php
Normal file
133
src/Exceptions/MissingWorkspaceContextException.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Exception thrown when a workspace-scoped operation is attempted without workspace context.
|
||||
*
|
||||
* This is a SECURITY exception - it prevents cross-tenant data access by failing fast
|
||||
* when workspace context is missing, rather than falling back to a default workspace.
|
||||
*/
|
||||
class MissingWorkspaceContextException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message = 'Workspace context is required for this operation.',
|
||||
public readonly ?string $operation = null,
|
||||
public readonly ?string $model = null,
|
||||
int $code = 403,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for a model operation.
|
||||
*/
|
||||
public static function forModel(string $model, string $operation = 'query'): self
|
||||
{
|
||||
return new self(
|
||||
message: "Workspace context is required to {$operation} {$model}. No workspace is currently set.",
|
||||
operation: $operation,
|
||||
model: $model
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for creating a model.
|
||||
*/
|
||||
public static function forCreate(string $model): self
|
||||
{
|
||||
return new self(
|
||||
message: "Cannot create {$model} without workspace context. Ensure a workspace is set before creating workspace-scoped resources.",
|
||||
operation: 'create',
|
||||
model: $model
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for query scope.
|
||||
*/
|
||||
public static function forScope(string $model): self
|
||||
{
|
||||
return new self(
|
||||
message: "Cannot apply workspace scope to {$model} without workspace context. Use ->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;
|
||||
}
|
||||
}
|
||||
79
src/Features/ApolloTier.php
Normal file
79
src/Features/ApolloTier.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Features;
|
||||
|
||||
use Core\Mod\Tenant\Enums\UserTier;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Services\EntitlementService;
|
||||
|
||||
class ApolloTier
|
||||
{
|
||||
public function __construct(
|
||||
protected EntitlementService $entitlements
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve the feature's initial value.
|
||||
* Apollo tier is active if:
|
||||
* - User's default workspace has 'tier.apollo' or 'tier.hades' feature, OR
|
||||
* - User has Apollo or Hades tier on their profile (legacy fallback)
|
||||
*/
|
||||
public function resolve(mixed $scope): bool
|
||||
{
|
||||
// Check workspace entitlements first
|
||||
if ($scope instanceof Workspace) {
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
42
src/Features/BetaFeatures.php
Normal file
42
src/Features/BetaFeatures.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Features;
|
||||
|
||||
use Illuminate\Support\Lottery;
|
||||
|
||||
class BetaFeatures
|
||||
{
|
||||
/**
|
||||
* New dashboard design.
|
||||
*/
|
||||
public static function newDashboard(): bool
|
||||
{
|
||||
return false; // Enable when ready
|
||||
}
|
||||
|
||||
/**
|
||||
* AI-powered content suggestions.
|
||||
*/
|
||||
public static function aiSuggestions(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time notifications via Reverb.
|
||||
*/
|
||||
public static function realtimeNotifications(): bool
|
||||
{
|
||||
return true; // Enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced analytics dashboard.
|
||||
*/
|
||||
public static function advancedAnalytics(): bool
|
||||
{
|
||||
return Lottery::odds(1, 10)->choose(); // 10% rollout
|
||||
}
|
||||
}
|
||||
70
src/Features/HadesTier.php
Normal file
70
src/Features/HadesTier.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Features;
|
||||
|
||||
use Core\Mod\Tenant\Enums\UserTier;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Services\EntitlementService;
|
||||
|
||||
class HadesTier
|
||||
{
|
||||
public function __construct(
|
||||
protected EntitlementService $entitlements
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve the feature's initial value.
|
||||
* Hades tier is active if:
|
||||
* - User's default workspace has 'tier.hades' feature, OR
|
||||
* - User has Hades tier on their profile (legacy fallback)
|
||||
*/
|
||||
public function resolve(mixed $scope): bool
|
||||
{
|
||||
// Check workspace entitlements first
|
||||
if ($scope instanceof Workspace) {
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
75
src/Features/UnlimitedWorkspaces.php
Normal file
75
src/Features/UnlimitedWorkspaces.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Features;
|
||||
|
||||
use Core\Mod\Tenant\Enums\UserTier;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Services\EntitlementService;
|
||||
|
||||
class UnlimitedWorkspaces
|
||||
{
|
||||
public function __construct(
|
||||
protected EntitlementService $entitlements
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve the feature's initial value.
|
||||
* Unlimited workspaces if:
|
||||
* - User's workspace has 'tier.hades' feature, OR
|
||||
* - User has Hades tier on their profile (legacy fallback)
|
||||
*/
|
||||
public function resolve(mixed $scope): bool
|
||||
{
|
||||
// Check workspace entitlements first
|
||||
if ($scope instanceof Workspace) {
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
43
src/Jobs/ComputeUserStats.php
Normal file
43
src/Jobs/ComputeUserStats.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Jobs;
|
||||
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Services\UserStatsService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ComputeUserStats implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 30;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public int $userId
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(UserStatsService $statsService): void
|
||||
{
|
||||
$user = User::find($this->userId);
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statsService->computeStats($user);
|
||||
}
|
||||
}
|
||||
188
src/Jobs/DispatchEntitlementWebhook.php
Normal file
188
src/Jobs/DispatchEntitlementWebhook.php
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Jobs;
|
||||
|
||||
use Core\Mod\Tenant\Enums\WebhookDeliveryStatus;
|
||||
use Core\Mod\Tenant\Models\EntitlementWebhook;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Job to dispatch entitlement webhook deliveries asynchronously.
|
||||
*
|
||||
* Handles retry logic with exponential backoff.
|
||||
*/
|
||||
class DispatchEntitlementWebhook implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying.
|
||||
*
|
||||
* @var array<int>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
public function tags(): array
|
||||
{
|
||||
return [
|
||||
'entitlement-webhook',
|
||||
"webhook:{$this->webhookId}",
|
||||
"event:{$this->eventName}",
|
||||
];
|
||||
}
|
||||
}
|
||||
130
src/Jobs/ProcessAccountDeletion.php
Normal file
130
src/Jobs/ProcessAccountDeletion.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Jobs;
|
||||
|
||||
use Core\Mod\Tenant\Models\AccountDeletionRequest;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Process a single account deletion request.
|
||||
*
|
||||
* This job handles the actual deletion of a user account and all
|
||||
* associated data. It's designed to be run either via queue dispatch
|
||||
* or by the scheduled ProcessAccountDeletions command.
|
||||
*/
|
||||
class ProcessAccountDeletion implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*/
|
||||
public int $backoff = 60;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public AccountDeletionRequest $deletionRequest
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
// Reload to ensure we have fresh data (may have been deleted)
|
||||
$request = AccountDeletionRequest::find($this->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<int, string>
|
||||
*/
|
||||
public function tags(): array
|
||||
{
|
||||
return [
|
||||
'account-deletion',
|
||||
'user:'.$this->deletionRequest->user_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
567
src/Lang/en_GB/tenant.php
Normal file
567
src/Lang/en_GB/tenant.php
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tenant module translations (en_GB).
|
||||
*
|
||||
* Multi-tenant workspace management translations.
|
||||
*/
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Workspace Home
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'workspace' => [
|
||||
'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.',
|
||||
],
|
||||
],
|
||||
];
|
||||
21
src/Listeners/SendWelcomeEmail.php
Normal file
21
src/Listeners/SendWelcomeEmail.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Listeners;
|
||||
|
||||
use Core\Mod\Tenant\Notifications\WelcomeNotification;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
||||
class SendWelcomeEmail implements ShouldQueue
|
||||
{
|
||||
/**
|
||||
* Handle the event.
|
||||
*/
|
||||
public function handle(Registered $event): void
|
||||
{
|
||||
// Send welcome email after registration (queued)
|
||||
$event->user->notify(new WelcomeNotification);
|
||||
}
|
||||
}
|
||||
62
src/Mail/AccountDeletionRequested.php
Normal file
62
src/Mail/AccountDeletionRequested.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Mail;
|
||||
|
||||
use Core\Mod\Tenant\Models\AccountDeletionRequest;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class AccountDeletionRequested extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public AccountDeletionRequest $deletionRequest
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Confirm Your Account Deletion Request',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'tenant::emails.account-deletion-requested',
|
||||
with: [
|
||||
'user' => $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<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
96
src/Middleware/CheckWorkspacePermission.php
Normal file
96
src/Middleware/CheckWorkspacePermission.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Services\WorkspaceTeamService;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Middleware to check if the current user has a specific workspace permission.
|
||||
*
|
||||
* Usage in routes:
|
||||
* Route::middleware('workspace.permission:bio.write')
|
||||
* Route::middleware('workspace.permission:workspace.manage_settings,workspace.manage_members')
|
||||
*
|
||||
* The middleware checks if the user has ANY of the specified permissions (OR logic).
|
||||
* Use multiple middleware definitions for AND logic.
|
||||
*/
|
||||
class CheckWorkspacePermission
|
||||
{
|
||||
public function __construct(
|
||||
protected WorkspaceTeamService $teamService
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next, string ...$permissions): Response
|
||||
{
|
||||
$user = $request->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;
|
||||
}
|
||||
}
|
||||
34
src/Middleware/RequireAdminDomain.php
Normal file
34
src/Middleware/RequireAdminDomain.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RequireAdminDomain
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* Ensures admin routes are only accessible from admin domains.
|
||||
* Service subdomains (social.host.uk.com, etc.) get redirected to their public pages.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$isAdminDomain = $request->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]);
|
||||
}
|
||||
}
|
||||
118
src/Middleware/RequireWorkspaceContext.php
Normal file
118
src/Middleware/RequireWorkspaceContext.php
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Core\Mod\Tenant\Exceptions\MissingWorkspaceContextException;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Middleware that ensures workspace context is established before processing the request.
|
||||
*
|
||||
* SECURITY: Use this middleware on routes that handle workspace-scoped data to prevent
|
||||
* accidental cross-tenant data access. This middleware:
|
||||
*
|
||||
* 1. Verifies workspace context exists in the request
|
||||
* 2. Throws MissingWorkspaceContextException if missing (fails fast)
|
||||
* 3. Optionally validates the user has access to the workspace
|
||||
*
|
||||
* Usage in routes:
|
||||
* Route::middleware(['auth', 'workspace.required'])->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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/Middleware/ResolveNamespace.php
Normal file
59
src/Middleware/ResolveNamespace.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Core\Mod\Tenant\Services\NamespaceService;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Middleware to resolve the current namespace from session/request.
|
||||
*
|
||||
* Sets the current namespace in request attributes for use by
|
||||
* BelongsToNamespace trait and other components.
|
||||
*/
|
||||
class ResolveNamespace
|
||||
{
|
||||
public function __construct(
|
||||
protected NamespaceService $namespaceService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Try to resolve namespace from query parameter first
|
||||
if ($namespaceUuid = $request->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);
|
||||
}
|
||||
}
|
||||
142
src/Middleware/ResolveWorkspaceFromSubdomain.php
Normal file
142
src/Middleware/ResolveWorkspaceFromSubdomain.php
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Services\WorkspaceService;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ResolveWorkspaceFromSubdomain
|
||||
{
|
||||
/**
|
||||
* Subdomains that serve the admin panel (main domain aliases).
|
||||
*/
|
||||
protected array $adminSubdomains = ['hub', 'www', 'hestia', 'main', ''];
|
||||
|
||||
public function __construct(
|
||||
protected WorkspaceService $workspaceService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* Resolves workspace from subdomain: {workspace}.host.uk.com
|
||||
* - Admin subdomains (hub, www, hestia) → full admin panel access
|
||||
* - Service subdomains (social, push, etc.) → public workspace pages only
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$host = $request->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';
|
||||
}
|
||||
}
|
||||
316
src/Migrations/0001_01_01_000000_create_tenant_tables.php
Normal file
316
src/Migrations/0001_01_01_000000_create_tenant_tables.php
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Core tenant tables - users, workspaces, namespaces, entitlements.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
// 1. Users
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->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();
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Workspace invitations table for inviting users to join workspaces.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('workspace_invitations', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Track usage alert notifications to avoid spamming users.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('entitlement_usage_alert_history', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Entitlement webhooks for notifying external systems about usage events.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('entitlement_webhooks', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Workspace teams and enhanced member pivot for role-based access control.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. Create workspace teams table
|
||||
Schema::create('workspace_teams', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
160
src/Models/AccountDeletionRequest.php
Normal file
160
src/Models/AccountDeletionRequest.php
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AccountDeletionRequest extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'token',
|
||||
'reason',
|
||||
'expires_at',
|
||||
'confirmed_at',
|
||||
'completed_at',
|
||||
'cancelled_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => '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]);
|
||||
}
|
||||
}
|
||||
110
src/Models/AgentReferralBonus.php
Normal file
110
src/Models/AgentReferralBonus.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AgentReferralBonus extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'provider',
|
||||
'model',
|
||||
'next_referral_guaranteed',
|
||||
'last_conversion_at',
|
||||
'total_conversions',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'next_referral_guaranteed' => '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;
|
||||
}
|
||||
}
|
||||
220
src/Models/Boost.php
Normal file
220
src/Models/Boost.php
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Boost extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'entitlement_boosts';
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'namespace_id',
|
||||
'user_id',
|
||||
'feature_code',
|
||||
'boost_type',
|
||||
'duration_type',
|
||||
'limit_value',
|
||||
'consumed_quantity',
|
||||
'status',
|
||||
'starts_at',
|
||||
'expires_at',
|
||||
'blesta_addon_id',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'limit_value' => '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]);
|
||||
}
|
||||
}
|
||||
207
src/Models/EntitlementLog.php
Normal file
207
src/Models/EntitlementLog.php
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EntitlementLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'entitlement_logs';
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'namespace_id',
|
||||
'action',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'user_id',
|
||||
'source',
|
||||
'old_values',
|
||||
'new_values',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'old_values' => '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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
245
src/Models/EntitlementWebhook.php
Normal file
245
src/Models/EntitlementWebhook.php
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Core\Mod\Tenant\Contracts\EntitlementWebhookEvent;
|
||||
use Core\Mod\Tenant\Enums\WebhookDeliveryStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Webhook configuration for entitlement events.
|
||||
*
|
||||
* Allows external systems to receive notifications about
|
||||
* usage alerts, package changes, and boost activity.
|
||||
*/
|
||||
class EntitlementWebhook extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'entitlement_webhooks';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'workspace_id',
|
||||
'name',
|
||||
'url',
|
||||
'secret',
|
||||
'events',
|
||||
'is_active',
|
||||
'max_attempts',
|
||||
'last_delivery_status',
|
||||
'last_triggered_at',
|
||||
'failure_count',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'events' => '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;
|
||||
}
|
||||
}
|
||||
139
src/Models/EntitlementWebhookDelivery.php
Normal file
139
src/Models/EntitlementWebhookDelivery.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Core\Mod\Tenant\Enums\WebhookDeliveryStatus;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\MassPrunable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Record of an entitlement webhook delivery attempt.
|
||||
*
|
||||
* Tracks successful and failed deliveries for debugging
|
||||
* and retry purposes.
|
||||
*/
|
||||
class EntitlementWebhookDelivery extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use MassPrunable;
|
||||
|
||||
protected $table = 'entitlement_webhook_deliveries';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'webhook_id',
|
||||
'uuid',
|
||||
'event',
|
||||
'attempts',
|
||||
'status',
|
||||
'http_status',
|
||||
'resend_at',
|
||||
'resent_manually',
|
||||
'payload',
|
||||
'response',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'attempts' => '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',
|
||||
};
|
||||
}
|
||||
}
|
||||
159
src/Models/Feature.php
Normal file
159
src/Models/Feature.php
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Feature extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'entitlement_features';
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'category',
|
||||
'type',
|
||||
'reset_type',
|
||||
'rolling_window_days',
|
||||
'parent_feature_id',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'rolling_window_days' => '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;
|
||||
}
|
||||
}
|
||||
176
src/Models/NamespacePackage.php
Normal file
176
src/Models/NamespacePackage.php
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Package assignment to a namespace for entitlement tracking.
|
||||
*
|
||||
* Namespace-level packages allow for granular entitlement control
|
||||
* separate from workspace-level packages. The entitlement cascade is:
|
||||
* 1. Namespace packages (checked first)
|
||||
* 2. Workspace packages (fallback if namespace has workspace context)
|
||||
* 3. User tier (final fallback for user-owned namespaces)
|
||||
*/
|
||||
class NamespacePackage extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'entitlement_namespace_packages';
|
||||
|
||||
protected $fillable = [
|
||||
'namespace_id',
|
||||
'package_id',
|
||||
'status',
|
||||
'starts_at',
|
||||
'expires_at',
|
||||
'billing_cycle_anchor',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'starts_at' => '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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
321
src/Models/Namespace_.php
Normal file
321
src/Models/Namespace_.php
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Namespace model - universal tenant boundary for products.
|
||||
*
|
||||
* A namespace provides a clean ownership boundary where products belong to
|
||||
* a namespace rather than directly to User/Workspace. The namespace itself
|
||||
* has polymorphic ownership (User or Workspace can own).
|
||||
*
|
||||
* Ownership patterns:
|
||||
* - Individual user: User → Namespace → Products
|
||||
* - Agency: Workspace → Namespace(s) → Products (one per client)
|
||||
* - Team member: User in Workspace → access to Workspace's Namespaces
|
||||
*/
|
||||
class Namespace_ extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*/
|
||||
protected $table = 'namespaces';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'icon',
|
||||
'color',
|
||||
'owner_type',
|
||||
'owner_id',
|
||||
'workspace_id',
|
||||
'settings',
|
||||
'is_default',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*/
|
||||
protected $casts = [
|
||||
'settings' => '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';
|
||||
}
|
||||
}
|
||||
244
src/Models/Package.php
Normal file
244
src/Models/Package.php
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Package extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'entitlement_packages';
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'icon',
|
||||
'color',
|
||||
'sort_order',
|
||||
'is_stackable',
|
||||
'is_base_package',
|
||||
'is_active',
|
||||
'is_public',
|
||||
'blesta_package_id',
|
||||
// Pricing fields
|
||||
'monthly_price',
|
||||
'yearly_price',
|
||||
'setup_fee',
|
||||
'trial_days',
|
||||
'stripe_price_id_monthly',
|
||||
'stripe_price_id_yearly',
|
||||
'btcpay_price_id_monthly',
|
||||
'btcpay_price_id_yearly',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_stackable' => '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');
|
||||
}
|
||||
}
|
||||
198
src/Models/UsageAlertHistory.php
Normal file
198
src/Models/UsageAlertHistory.php
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Tracks usage alert notifications to avoid spamming users.
|
||||
*
|
||||
* When a workspace approaches an entitlement limit (e.g., 80% used),
|
||||
* an alert is sent. This model tracks which alerts have been sent
|
||||
* and when, so we don't send duplicates.
|
||||
*/
|
||||
class UsageAlertHistory extends Model
|
||||
{
|
||||
protected $table = 'entitlement_usage_alert_history';
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'feature_code',
|
||||
'threshold',
|
||||
'notified_at',
|
||||
'resolved_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'threshold' => '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',
|
||||
};
|
||||
}
|
||||
}
|
||||
121
src/Models/UsageRecord.php
Normal file
121
src/Models/UsageRecord.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class UsageRecord extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'entitlement_usage_records';
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'namespace_id',
|
||||
'feature_code',
|
||||
'quantity',
|
||||
'user_id',
|
||||
'metadata',
|
||||
'recorded_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => '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');
|
||||
}
|
||||
}
|
||||
596
src/Models/User.php
Normal file
596
src/Models/User.php
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Core\Mod\Tenant\Enums\UserTier;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Pennant\Concerns\HasFeatures;
|
||||
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
use HasFactory, HasFeatures, Notifiable;
|
||||
|
||||
/**
|
||||
* Create a new factory instance for the model.
|
||||
*/
|
||||
protected static function newFactory(): \Core\Mod\Tenant\Database\Factories\UserFactory
|
||||
{
|
||||
return \Core\Mod\Tenant\Database\Factories\UserFactory::new();
|
||||
}
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
126
src/Models/UserToken.php
Normal file
126
src/Models/UserToken.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Personal access token for API authentication.
|
||||
*
|
||||
* Provides stateful API authentication using long-lived tokens.
|
||||
* Tokens are hashed using SHA-256 before storage for security.
|
||||
*/
|
||||
class UserToken extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected static function newFactory(): \Core\Mod\Tenant\Database\Factories\UserTokenFactory
|
||||
{
|
||||
return \Core\Mod\Tenant\Database\Factories\UserTokenFactory::new();
|
||||
}
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'user_tokens';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'token',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'last_used_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
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<User, UserToken>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/Models/UserTwoFactorAuth.php
Normal file
38
src/Models/UserTwoFactorAuth.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* User two-factor authentication record.
|
||||
*
|
||||
* Stores TOTP secrets and recovery codes for 2FA.
|
||||
*/
|
||||
class UserTwoFactorAuth extends Model
|
||||
{
|
||||
protected $table = 'user_two_factor_auth';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'secret_key',
|
||||
'recovery_codes',
|
||||
'confirmed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'recovery_codes' => 'collection',
|
||||
'confirmed_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user this 2FA belongs to.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
126
src/Models/WaitlistEntry.php
Normal file
126
src/Models/WaitlistEntry.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class WaitlistEntry extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use Notifiable;
|
||||
|
||||
protected static function newFactory(): \Core\Mod\Tenant\Database\Factories\WaitlistEntryFactory
|
||||
{
|
||||
return \Core\Mod\Tenant\Database\Factories\WaitlistEntryFactory::new();
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'email',
|
||||
'name',
|
||||
'source',
|
||||
'interest',
|
||||
'invite_code',
|
||||
'invited_at',
|
||||
'registered_at',
|
||||
'user_id',
|
||||
'notes',
|
||||
'bonus_code',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'invited_at' => '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();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue