monorepo sepration

This commit is contained in:
Snider 2026-01-26 21:08:59 +00:00
parent 496551ee53
commit bc9ffd74d3
170 changed files with 26922 additions and 587 deletions

View file

@ -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
View file

@ -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
View file

@ -1,137 +1,131 @@
# Core PHP Framework Project
# Core Tenant
[![CI](https://github.com/host-uk/core-template/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/core-template/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/host-uk/core-template/graph/badge.svg)](https://codecov.io/gh/host-uk/core-template)
[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/core-template)](https://packagist.org/packages/host-uk/core-template)
[![Laravel](https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel)](https://laravel.com)
[![CI](https://github.com/host-uk/core-tenant/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/core-tenant/actions/workflows/ci.yml)
[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/core-tenant)](https://packagist.org/packages/host-uk/core-tenant)
[![Laravel](https://img.shields.io/badge/Laravel-11.x%20%7C%2012.x-FF2D20?logo=laravel)](https://laravel.com)
[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE)
A modular monolith Laravel application built with Core PHP Framework.
Multi-tenancy module for the Core PHP Framework providing users, workspaces, and entitlements.
## Features
- **Core Framework** - Event-driven module system with lazy loading
- **Admin Panel** - Livewire-powered admin interface with Flux UI
- **REST API** - Scoped API keys, rate limiting, webhooks, OpenAPI docs
- **MCP Tools** - Model Context Protocol for AI agent integration
- **Users & Authentication** - User management with 2FA support
- **Workspaces** - Multi-tenant workspace boundaries
- **Entitlements** - Feature access, packages, and usage tracking
- **Account Management** - User settings, account deletion
- **Referrals** - Referral system support
- **Usage Alerts** - Configurable usage threshold alerts
## Requirements
- PHP 8.2+
- Composer 2.x
- SQLite (default) or MySQL/PostgreSQL
- Node.js 18+ (for frontend assets)
- Laravel 11.x or 12.x
- Core PHP Framework (`host-uk/core`)
## Installation
```bash
# Clone or create from template
git clone https://github.com/host-uk/core-template.git my-project
cd my-project
# Install dependencies
composer install
npm install
# Configure environment
cp .env.example .env
php artisan key:generate
# Set up database
touch database/database.sqlite
php artisan migrate
# Start development server
php artisan serve
composer require host-uk/core-tenant
```
Visit: http://localhost:8000
The service provider will be auto-discovered.
## Project Structure
```
app/
├── Console/ # Artisan commands
├── Http/ # Controllers & Middleware
├── Models/ # Eloquent models
├── Mod/ # Your custom modules
└── Providers/ # Service providers
config/
└── core.php # Core framework configuration
routes/
├── web.php # Public web routes
├── api.php # REST API routes
└── console.php # Artisan commands
```
## Creating Modules
Run migrations:
```bash
# Create a new module with all features
php artisan make:mod Blog --all
# Create module with specific features
php artisan make:mod Shop --web --api --admin
php artisan migrate
```
Modules follow the event-driven pattern:
## Usage
### Workspace Management
```php
<?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

View file

View file

View file

@ -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
View file

@ -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);

View file

@ -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();

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,5 +0,0 @@
<?php
return [
App\Providers\AppServiceProvider::class,
];

View file

@ -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": {

View file

@ -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'),
],
];

View file

@ -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
}
}

View file

@ -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"
}
}

View file

@ -14,7 +14,7 @@
</testsuites>
<source>
<include>
<directory>app</directory>
<directory>src</directory>
</include>
</source>
<php>

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -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>

View file

@ -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());

View file

@ -1,2 +0,0 @@
User-agent: *
Disallow:

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -1 +0,0 @@
import './bootstrap';

View file

@ -1,3 +0,0 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

View file

@ -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>

View file

@ -1,5 +0,0 @@
<?php
use Illuminate\Support\Facades\Route;
// API routes are registered via Core modules

View file

@ -1,3 +0,0 @@
<?php
// Console commands are registered via Core modules

View file

@ -1,7 +0,0 @@
<?php
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});

173
src/Boot.php Normal file
View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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!');
}
}

View 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.');
}
}
}

View 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;
}

View 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;
}

View 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');
}
}
}

View 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);
}
}

View 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));
}
}

View 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);
}
}

View 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}.");
}
}

View 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,
]);
}
}

View 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),
]);
}
}

View 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'),
]);
}
}

View 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',
]);
}
}

View 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',
]);
}
}

View 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,
]);
}
}

View 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}");
}
}

View 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.');
}
}

View 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);
}
}
}

View 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
View 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());
}
}

View 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';
}

View 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}";
}
}

View 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}";
}
}

View 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}";
}
}

View 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}";
}
}

View 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}";
}
}

View 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());
}
}

View 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;
}
}

View 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;
}
}

View 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
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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}",
];
}
}

View 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
View 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.',
],
],
];

View 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);
}
}

View 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 [];
}
}

View 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;
}
}

View 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]);
}
}

View 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
);
}
}
}
}

View 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);
}
}

View 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';
}
}

View 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();
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View 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]);
}
}

View 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
View 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]);
}
}

View 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,
]);
}
}

View 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;
}
}

View 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
View 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;
}
}

View 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
View 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
View 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');
}
}

View 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
View 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
View 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
View 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();
}
}
}

View 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);
}
}

View 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